## 1. Persistent theme-catalog helper - [x] 1.1 Split `tools/theme_catalog.py`'s `main()` into a reusable `build_parser()`/`handle(args)` with no behavior change; verify with the existing `python3 -m unittest tests.test_theme_catalog` (10 cases, all still passing) and `tests/test_theme_commit_under_occlusion_runtime.py`'s subprocess invocation still working unmodified. - [x] 1.2 Add `tools/theme_helperd.py`: a private-socket daemon that calls that exact parser/handler per request, survives a malformed request without wedging, and shuts down cleanly on SIGTERM. Verify with `python3 -m unittest tests.test_theme_helper_daemon`. - [x] 1.3 Add `tools/theme_client.py`: an argv-compatible front end that tries the daemon first without importing `theme_catalog` eagerly, and falls back to the unmodified in-process path otherwise. Verify the import-avoidance itself with the `-X importtime` subprocess case in `tests/test_theme_helper_daemon.py`, and correctness/fallback with the rest of that file. - [x] 1.4 Wire `nix/shell.nix`'s `theme-helper.service` (guarded by `cfg.coherentShell`, same posture as `shell-ui.service`) and point `nix/handheld-theme-command.nix`'s `k230-theme` wrapper at `theme_client.py`. Verify with `nix build --no-link --print-out-paths --max-jobs 1 --cores 6 .#handheld-theme-command` and a manual run of the built `k230-theme-helperd`/`k230-theme` pair under `qemu-riscv64-static` (see `docs/evidence/omarchy-themes/theme-swap-jank/README.md`). Proof: `python3 -m unittest tests.test_theme_catalog tests.test_theme_helper_daemon` and the `nix build` above. ## 2. Measure jank, not just latency - [x] 2.1 Add `tools/theme-swap-jank.py`, a root board-side capture tool: triggers a swap, samples per-process CPU from `/proc`, and collects a merged wall-clock timeline from the Rust shell's own `commit`/ `wallpaper-commit`/`frame-done` log lines and `K230_CARD_SHELL` sway_log lines via `journalctl -o json`. Verify with `python3 tools/theme-swap-jank.py --self-test`. - [x] 2.2 Add `tools/analyze-theme-swap-jank.py`: frame-interval histogram, gaps over 33/50/100 ms with bounding-event context, the longest stall and its surrounding events, tap-to-visible latency against a configurable target, and per-process CPU. Verify with `python3 tools/analyze-theme-swap-jank.py --self-test`. - [x] 2.3 MOVED, NOT PERFORMED HERE: Run the capture tool on the reserved board across a representative swap (a bundled dark theme, a bundled light theme, and one community theme with a still background), before and after this change's `theme-helper.service` lands on an installed system. Record `docs/evidence/omarchy-themes/theme-swap-jank/README.md` with the exact `journalctl`/board commands and both reports. **Needs the reserved board; not run by this change.** Moved 2026-10-12 on the operator-authorized scope split: carried verbatim into the successor change `the-theme-swap-is-profiled-on-the-board` (task 1.1). Not performed here; no result is claimed. Proof for 2.1/2.2: the two tools' own `--self-test`. Proof for 2.3: the committed before/after JSON reports and the exact board commands that produced them. ## 3. Explicit remainder (successor work, partially started here) - [x] 3.1a Expose the clean prepare-ahead API: `tools/theme_transaction.py`'s `prepare_only(generation, ...)` sends the two-phase protocol's existing "prepare" message (the same one `activate_generation()` already sends immediately before every commit -- `appearance.rs`'s Prepare handling already decodes/caches the wallpaper there) without committing, taking no lock, safe to call repeatedly for different candidates. Wired into `theme_catalog.py`'s `preview` action so browsing already warms both receivers when `--rust-socket`/`--deck-socket` are configured. Verify with `python3 -m unittest tests.test_omarchy_theme_transaction tests.test_theme_catalog`. - [x] 3.1b The actual in-memory buffer swap on commit, on both receivers: - Rust chooser (`background_decode.rs`): `BackgroundCache` was a single-slot cache, so browsing a *different* candidate (task 3.2, or the Preview page's own background carousel) evicted an already-prepared one before its own commit landed, falling back to a `background.cache` file read. It is now a small bounded LRU (`CACHE_CAPACITY = 4`), so commit is a guaranteed in-memory hit for any of the last few prepared candidates, not just the single most recent. Verify with `cargo test --offline --test background_decode_module` (adds `multiple_recent_candidates_stay_warm_without_evicting_each_other`). - Compositor (`nix/card-shell/appearance.c`/`adapter.c`): `appearance.c` gained an advisory `card_appearance_prepare_fn` hook, called once per successfully validated `prepare`, before its ack (never able to affect prepare's own accept/reject decision). `adapter.c`'s `appearance_prepare` uses it to pre-build the deck's gradient (`card_brush_scene`) for the *candidate* generation, disabled/detached until a matching commit; `appearance_canvas_refresh` adopts it at commit (destroy old, promote pointer, enable+position) instead of repainting, falling back to today's inline build whenever nothing warm matches (dimension/brush/generation changed, or nothing was prepared ahead) -- purely advisory, like `background.cache` itself. Verify with `python3 -m unittest tests.test_card_shell_appearance` (extended for the new `PREPARE` hook line) and `nix build --no-link --print-out-paths --max-jobs 1 --cores 6 .#card-shell` (real wlroots/Sway types; this task's C changes cannot be host-unit-tested beyond the protocol-level hook). - [x] 3.2 Call `prepare_only()` (3.1a) as a theme becomes the centred carousel item, before Apply. Implemented in `theme_ui.rs` (`ThemeView::poll_prepare_ahead`/`prepare_ahead_submitted`/ `prepare_ahead_reply`) and wired from `main.rs`'s existing per-tick carousel-settle loop: once the centred index is unchanged for 220 ms with the carousel at rest (`!Carousel::is_animating()`), it submits the existing `ThemeRequest::Preview` (background id `None`) directly to the shared `ThemeWorker`, bounded to one in-flight warm-up at a time (the stricter half of "one or two"), and the very next settled index simply replaces the desired target rather than queuing a backlog -- "cancelled on scroll-away" in the sense that a candidate passed through while still moving, or superseded before its debounce elapsed, is never submitted. Its reply is recognised by `prepare_ahead_reply` and discarded before `ThemeView::accept` ever sees it, so it can never navigate or repaint the chooser. Reuses the existing `preview` action (already calling `prepare_only()` per 3.1a) rather than adding a new verb or protocol. Verify with `cargo test --offline --lib theme_ui`. - [x] 3.3a Defer `wvkbd` recolour/restart until after the visible swap commits and this response is otherwise ready, so it never blocks `activated: true`. `theme_catalog.py`'s `activate` action now reports `keyboard_appearance: {"state": "deferred"}` immediately and runs `keyboard_appearance.sync_and_restart` on a background (non-daemon) thread; `theme-helper.service`'s request loop sends its reply the moment `handle()` returns, independent of that thread, so the daemon-served path drops this cost entirely, while a bare CLI subprocess is unaffected (the interpreter already waits for a non-daemon thread at process exit, so its total wall-clock time is unchanged). Verify with `python3 -m unittest tests.test_theme_catalog` (adds `test_activate_reports_keyboard_sync_as_deferred_but_it_still_completes`). - [x] 3.3b Defer Foot recolour after acknowledged activation. The catalogue opts into `activate_generation(..., defer_app_sync=True)` and reports `app_appearance: {"state": "deferred"}`. Direct callers keep the synchronous result. A non-daemon thread invokes the same guarded app adapter, including its shared GTK keyfile, without changing prepare/commit/rollback. Late old work refuses a newer generation; failures are logged without shell rollback. The Rust consumer accepts the new status through both transports and retains the normal applied message. Named host proof: `python3 -m unittest tests.test_omarchy_theme_transaction tests.test_theme_catalog tests.test_theme_helper_daemon tests.test_handheld_app_themes` (70 pass), `cargo test --offline --manifest-path nix/rust-shell-client/Cargo.toml --test theme_catalog_module` (6 pass), and `cargo test --offline --manifest-path nix/rust-shell-client/Cargo.toml --lib theme_ui` (28 pass). Package proof: `nix build .#handheld-shell-rust .#handheld-theme-command` (both built). Committed proof and artifact identities: `docs/evidence/theme-picker/foot-deferral-2026-10-10/README.md`. No new board observation or latency claim; deploy the compatible helper and Rust client together before qualifying that physical behavior. - [x] 3.4 MOVED, NOT PERFORMED HERE: Re-run `tools/analyze-theme-swap-jank.py`'s tap-to-visible metric against the board once 3.1b/3.2/3.3 land, and report against the ~100 ms target. Needs the reserved board; 3.1b/3.2/3.3 now have source/package proof. The named measurement remains unperformed and was deferred by the user. Moved 2026-10-12 on the operator-authorized scope split: carried verbatim into the successor change `the-theme-swap-is-profiled-on-the-board` (task 1.2). Not performed here; no result is claimed. ## 4. The board number was not instant either: `prepare()`'s own cost Once 3.1b/3.2/3.3a were on the board, `k230-theme preview`/`activate` of an *already-prepared* `catppuccin`/`catppuccin-latte` (a generation that already existed, with its own `background.cache`) still took 3.95-4.26 s each -- slower than the pre-daemon 2.8-3.5 s baseline, and `journalctl -u theme-helper` showed no entries at all, because nothing on this path had ever logged anything. - [x] 4.1 Root-cause: `theme_activate.py`'s `prepare()` checked whether the destination generation already existed only at the very end of the function -- after staging every asset (a byte-for-byte copy of every background image), invoking `omarchy-theme-color --all` and `omarchy-theme-set-templates` (each itself spawning several `awk`/`sed` subprocesses), and (for a still background) building the wallpaper cache. All of that ran, and was discarded, on *every* call, even for a generation prepared thousands of times before. `theme_client.py`'s own `--helper-timeout-s` compounded this: its `0.3` s default was sized for the daemon's own Python-import savings, not for this real (if unnecessary) `prepare()` cost, so almost every `preview`/`activate` timed out waiting on a *working* daemon reply and fell back to the full in-process path -- paying the wasted timeout, the ~1.2 s import chain, and `prepare()`'s cost a second time. Confirmed on this host: a repeat `prepare()` for the same generation dropped from ~250-370 ms to ~2-3 ms once the identity check moved first; the daemon-vs-fallback confusion is fixed by widening the client timeout to `10.0` s (see `theme_client.py`'s own comment for the exact reasoning) and by `theme_helperd.py`'s new per-request timing making the daemon's own answer time directly visible instead of inferred. - [x] 4.2 Fix: `theme_activate.py`'s `prepare()` now computes the generation identity (two content hashes plus a cheap background-name listing, no asset bytes copied and no external helper spawned) and checks for an existing destination *before* staging, invoking any `omarchy-theme-*` helper, or building a wallpaper cache. A cache hit returns the on-disk `report.json` (patched only for the one genuinely request-specific field, a since-removed remembered background) without touching `checked_copy`/`invoke`. Verify with `python3 -m unittest tests.test_omarchy_theme_activation` (adds `test_repeated_preparation_of_an_existing_generation_skips_staging_and_helpers` and `test_repeated_preparation_reports_a_newly_missing_remembered_background`, both asserting `checked_copy`/`invoke` raise if called on a cache hit). - [x] 4.3 Fix: `theme_client.py`'s `DEFAULT_TIMEOUT_S` raised from `0.3` to `10.0`. Verify with `python3 -m unittest tests.test_theme_helper_daemon` (adds `test_client_stays_on_the_daemon_for_a_legitimately_slow_but_working_reply`, a daemon reply mocked to take 0.6 s -- past the old default, comfortably inside the new one -- asserting `theme_client.fallback` is never called). - [x] 4.4 Instrumentation: a new `tools/theme_timing.py` (`Stopwatch` + `log()`) emits one `THEME_TIMING phase=Xms ...` line per request via `syslog` (never stdout/stderr, which `theme_catalog.rs`'s `command_error()` parses byte-for-byte as JSON on a nonzero exit -- seeing this in `journalctl` needs no code reading, only the exact commands in this change's evidence doc). Wired into `theme_activate.prepare()` (per-phase: hashes, background listing, cache hit/miss/race, staging, each external helper invocation, publish), `theme_transaction.exchange()` (per prepare/commit/rollback round trip, by endpoint), `theme_catalog.handle()` (per action, plus the deferred keyboard thread's own eventual completion), `theme_helperd.handle_line()` (request parse time and `self.lock` wait, separate from `handle()`'s own breakdown), and `theme_client.py` (which path was taken -- daemon or fallback -- and why a daemon attempt was abandoned, if it was). Also adds a `rust-shell ms theme-command duration=ms` line to `theme_catalog.rs`'s `execute()`, in the same shape `tools/theme-swap-jank.py`'s existing `RUST_LOG_RE` already parses, timing the chooser's own wait on the `k230-theme` subprocess. Verify with `python3 -m unittest tests.test_theme_timing` and `cargo test --offline`. - [x] 4.5 Re-run the coordinator's own board commands (see this change's evidence doc) once 4.1-4.4 are installed, and confirm `activate` of an already-prepared theme is now well under 500 ms end to end, with `journalctl` showing the daemon path taken and `prepare()` reporting `cache=hit`. **Board result (coordinator, system `46vdy1dy...`):** `activate` 1.27-1.38 s (was ~4.2 s), `preview` warm 1.06-1.08 s; `THEME_TIMING` showed `handle activate` total 245 ms (`discover` 42, `prepare_entry` 73, `activate_generation` 128, keyboard-deferred dispatch 3.6), `helperd` total 265 ms, `client socket_round_trip` 270 ms `path=daemon`, `exchange` prepare 10-20 ms/commit 7-14 ms per receiver, `keyboard_deferred sync_and_restart` 300 ms off the critical path. This confirmed the daemon path itself is fast (~250-270 ms); the ~1.0 s remaining per call is the `theme_client.py` Python-interpreter-plus- `runuser` subprocess start-up the Rust chooser still pays for every request -- named explicitly as task 5's own starting point below. Proof for 4.1-4.5: the tests named above (host), plus the board run above. ## 5. Talk to the daemon directly from the chooser; cut the remaining daemon cost - [x] 5.1 `nix/rust-shell-client/src/theme_catalog.rs`'s `ThemeWorker` speaks `theme-helper.service`'s own line protocol directly over `K230_THEME_HELPER_SOCKET` (`/run/shell/theme-helper.sock`, matching `theme_client.py`'s `DEFAULT_SOCKET`; set explicitly in `nix/shell.nix`) -- the exact same JSON-line request/reply shape `tools/theme_client.py`/`tools/theme_helperd.py` already speak to each other, so there is no second, drifting protocol. On any socket problem (missing, refused, timed out, malformed reply) it falls back to the existing `k230-theme` subprocess unchanged, which itself still tries the same daemon and falls back further, so a socket that is merely slow to *start* still degrades no worse than before this task. An empty `K230_THEME_HELPER_SOCKET` disables the direct path entirely, matching `K230_THEME_COMMAND`'s own unset-env convention. Verify with `cargo test --offline --test theme_catalog_module` (5 new cases: a working socket reply is used without ever invoking the subprocess; a daemon-reported error is returned without retrying via the subprocess; a missing socket, and a malformed reply, both fall back to the subprocess; an empty socket path disables the direct attempt entirely -- each proven by a "poison" fake subprocess that marks a file if it ever actually runs). - [x] 5.2 Cut `discover`'s ~42 ms: `theme_catalog.discover()` is now a cached wrapper (`_discover_uncached` does the real walk) keyed by `(user_themes, builtins)`, invalidated by a cheap stat-only fingerprint of exactly the directory levels `discover()` itself reads (each root's top level, and one level under a user entry's own `themes/` collection) -- never a content read or the identity-hashing/`find_preview()`/ `resolve()` work `discover()` itself does. A bare CLI process only ever calls this once per invocation (empty cache, no behaviour change); the daemon serves many requests against a catalog that, in the normal preview-then-activate chooser flow, has not changed. Verify with `python3 -m unittest tests.test_theme_catalog` (adds `test_discover_is_cached_until_the_catalog_directory_actually_changes`, proving an unchanged catalog is not re-walked and that adding or removing a theme is still noticed on the very next call). - [x] 5.3 Cut part of `prepare_entry`'s ~73 ms: `theme_activate.py`'s `helper_digest()` caches `source_digest(tools)` indefinitely, keyed by the tools directory's own resolved path. `tools` is `theme_helperd.py`'s own fixed `--tools` startup flag (a Nix store path in production), immutable for the daemon's whole lifetime, unlike the theme's own `source_hash` (deliberately left uncached: a person can edit their own theme's files while the daemon keeps running). Verify with `python3 -m unittest tests.test_omarchy_theme_activation` (adds `test_helper_digest_is_cached_across_repeated_preparations`, counting `source_digest` calls across two `prepare()` calls for the same tools path). - [x] 5.4 Re-run the coordinator's own board commands once 5.1-5.3 are installed, and confirm the chooser's own Apply-to-visible time. **Board result (coordinator, system `phj9y4fggpb177b0hgaawbh510chhwil`, `master` `f4f75998`, `docs/evidence/omarchy-themes/instant-theme-swap/ board-chooser-2026-09-25.md`):** `list` `path=socket` 166 ms, warm `preview` 206 ms, cold prepare-ahead (carousel opens) 4071 ms, tap Apply -> Rust `appearance-commit-accepted` 556 ms, `activate` `path=socket` 509 ms with helper breakdown `parse=65.0ms discover=2.0ms prepare_entry=90.6ms activate_generation=336.0ms` (handler total 438.6 ms, helper total 504.1 ms). The direct socket removed essentially all of the ~1.0 s subprocess/Python start-up as predicted (roughly 8x faster than the pre-daemon baseline), but still misses the ~150 ms target: `activate_generation` re-runs the two-phase prepare exchange during Apply even though the chooser's own prepare-ahead already prepared that exact generation, and `parse`/`prepare_entry` cost more than a cache hit should. This is section 6's own starting point below. Proof for 5.1-5.3: the tests named above, all passing on this host; proof for 5.4 is the board run quoted above. ## 6. Skip the redundant re-prepare on Apply; cut per-request parse/hash cost; warm neighbours early - [x] 6.1 Root-cause: `theme_helperd.py`'s `Helperd.handle_line()` called `theme_catalog.build_parser()` (constructing a fresh `argparse. ArgumentParser`, with all its subparsers/actions) on *every* request, not once at daemon start -- the board's 65 ms `parse` phase was almost entirely this construction, not argument parsing itself. Fix: build the parser once in `Helperd.__init__()` and reuse it. Verify with `python3 -m unittest tests.test_theme_helper_daemon` (adds `test_the_argument_parser_is_built_once_not_once_per_request`, counting `build_parser` calls across several requests on one daemon instance). - [x] 6.2 Root-cause (part of `prepare_entry`'s 91 ms): `theme_activate. prepare()` re-hashed the *theme's own* source tree (`source_digest(theme)`, a full content read/hash of every file) on every call, even for a generation prepared moments before by the same unchanged theme -- deliberately never cached before this task, because a person can edit their own theme's files while the daemon keeps running. Fix: `tools/theme_sources.py` gains `source_fingerprint(theme)`, a stat-only walk (name, size, `st_mtime_ns` per file, no content read) of exactly the same tree `source_digest()` walks; `theme_activate. theme_digest()` wraps `source_digest()` with a cache keyed by the theme's resolved path, invalidated only when `source_fingerprint()` itself changes -- so an edited file (which changes its size and/or mtime) is still re-hashed, and an unchanged theme is not. Verify with `python3 -m unittest tests.test_omarchy_theme_sources` (adds `test_fingerprint_matches_digest_sensitivity_without_reading_content`) and `tests.test_omarchy_theme_activation` (renames the existing digest- caching test to `test_theme_and_helper_digests_are_cached_across_ repeated_preparations` and adds `test_theme_digest_cache_is_invalidated_ by_an_edit_between_preparations`, editing a theme file between two `prepare()` calls and asserting a fresh digest/generation). - [x] 6.3 Root-cause (the largest single piece, `activate_generation`'s 336 ms): `tools/theme_transaction.py`'s `activate_generation()` always sent a fresh `prepare` to *both* receivers before `commit`, even when a receiver already held that exact generation staged from an earlier `exchange()` call (task 5's own prepare-ahead, or the chooser's own warm `preview`) -- there was no memory of which receiver was already warm for which generation. Fix: `exchange()` now records, per endpoint, the generation last successfully prepared for it (`_prepared_state: dict[Path, str]`, cleared on that endpoint's own commit or rollback); `activate_generation()` partitions its `targets` into `warm` (already holding this exact generation) and `to_prepare`, sends `prepare` only to `to_prepare`, and commits all `targets` unconditionally. If a "warm" receiver's commit is rejected anyway (the tracking was stale -- e.g. a concurrent caller reset that receiver without this process's knowledge), the code transparently falls back to a real `prepare` + `commit` for that one receiver before re-raising on any further failure, so a stale assumption degrades to exactly today's behaviour rather than a wrong commit, and full rollback correctness (all-or-nothing across receivers) is unchanged. Verify with `python3 -m unittest tests.test_omarchy_theme_transaction` (adds `test_activate_skips_a_prepare_already_warm_for_both_receivers`, `test_activate_prepares_only_the_receiver_that_was_not_already_warm`, `test_stale_warm_assumption_retries_with_a_real_prepare_and_still_ succeeds`, `test_a_genuine_commit_failure_for_a_warm_receiver_still_ rolls_back_fully`, and `test_exchange_tracks_prepared_state_across_ prepare_commit_rollback`). - [x] 6.4 Considered and explicitly **not implemented**: showing the new theme optimistically in the chooser UI on the tap frame, ahead of the durable two-phase commit finishing. This would mean the chooser's own displayed state could diverge from the receivers' actual committed state if the (now-mostly-skipped, but still real for a cold or stale generation) commit subsequently failed and rolled back -- exactly the "ack only after a real frame"/rollback-safety invariant task 1 and this change's own proposal treat as load-bearing. It is also unverifiable without the board (the whole point is perceived, on-glass timing), and 6.1-6.3 already remove the *avoidable* cost 6.4 was aimed at (a warm Apply no longer re-prepares at all). Left as an explicit candidate follow-up, not attempted here; see this task's own evidence doc for the full reasoning. - [x] 6.5 Extend task 3.2's prepare-ahead beyond the one centred/dwelled theme: `nix/rust-shell-client/src/theme_ui.rs`'s `ThemeView` gains `pending_neighbor_warms`, populated with the active theme's immediate list neighbours (one or two, whichever exist) every time a `list` reply is accepted -- covering both chooser-open and the return from Preview/ Cancel back to List. `poll_prepare_ahead()` still prioritises a real dwell-driven warm-up (task 3.2's own 220 ms centred-and-at-rest debounce) every tick, and only drains the neighbour queue (one request per idle tick, still bounded to one in flight, still discarded before reaching `ThemeView::accept`) when nothing dwell-driven is due -- so a person actively browsing is never delayed behind a neighbour warm-up, but a static chooser (or one returning from Preview) starts warming the two themes a first swipe is most likely to land on without waiting on any dwell at all. Verify with `cargo test --offline --lib theme_ui` (adds `a_list_reply_queues_both_neighbours_of_a_mid_list_active_theme`, `a_list_reply_queues_only_the_one_neighbour_at_each_end_of_the_list`, `a_single_theme_list_queues_no_neighbours`, `the_neighbour_queue_drains_when_nothing_is_dwell_driven`, and `a_dwell_driven_request_takes_priority_over_the_neighbour_queue`). - [x] 6.6 Re-run the coordinator's own board commands once 6.1-6.5 are installed, and confirm Apply of an already-prepared generation is well under 150 ms end to end, with `journalctl` showing `activate_generation` skip the redundant `prepare` for both receivers (a `warm=2` -- or similar -- log line, or simply the absence of a second `appearance- prepare-accepted` between the chooser's own warm-up and its Apply) and `parse`/`prepare_entry` dropping close to their cache-hit floor. Also confirm the cold-open case: with 6.5 installed, the *second* theme a person previews after opening the chooser (having not touched the first one long enough to trigger its own dwell) should already be warm from the neighbour queue, not a fresh ~4 s `preview`. Needs the reserved board; not run by this task -- see this task's own evidence doc for the exact commands. **Run 2026-10-12, task stays open (two cold-process trials; `docs/evidence/theme-picker/board-session-2026-10-12/README.md`):** Apply of an already-prepared generation was **not** under 150 ms (`activate_generation` 193 to 242 ms, activate round trip 253 to 314 ms). `activate_generation` did skip the redundant prepare for both receivers (`prepare_phase` 0.1 to 3.1 ms, `warm=2 targets=2`), but a second `appearance-prepare-accepted` still occurs at the tap because the chooser sends a `preview` first. The cold-open neighbour check **passed**: the second theme previewed 231 to 241 ms about 0.3 s after the first (380 ms), not a fresh ~4 s (process-cold, not disk-cold). Next measured correction: skip or make idempotent the tap-time `preview` for a generation already held. **Host proof for that correction (branch `perf/theme-tap-apply`; the board check remains open):** `ThemeView::tap_theme` now seeds `Desired::generation` from `known_generations` (learned by the browse-time warm) for a non-active theme, so the tap sends `Activate` directly with no `preview` and no second `appearance-prepare-accepted`; a rejected direct `Activate` evicts the cached generation so the retry previews. Separately, `RendererCache::content_generation()` no longer counts chooser changes with a bump-per-change counter (the tap's own press/release toggled `theme_pressed` twice and made the browse-time pre-render read `content-changed` although the view had returned to the identical state); it now folds in a value fingerprint of the compared chooser fields, so a reverted view matches again while a real change still does not. Host tests: `cargo test --offline --lib theme_ui` (36 passed, incl. `tap_theme_on_a_warmed_slice_skips_the_preview_and_activates_directly`, `a_rejected_direct_activate_evicts_the_known_generation_so_the_next_tap_previews`, `tap_theme_on_the_active_slice_ignores_a_known_generation`) and `cargo test --offline --lib render::tests` (incl. `a_pressed_wash_that_came_and_went_leaves_content_generation_unchanged`). No host test shows `prerendered=true` on glass; the board must re-run the 10.7/6.6 trial and show no tap-time `preview`, `prerendered=true`, and the new tap-to-shown time. The `content-changed` cause is inferred from code (UNVERIFIED on the board). **Board re-run 2026-10-12 (b), task stays open** (`docs/evidence/theme-picker/board-session-2026-10-12b/README.md`, "Task 6.6"; candidate `3m65ag9z`, source `e93b5a23`, two cold-process trials, injected taps): no tap-time `preview` and no second `appearance-prepare-accepted` follow a tap. The browse-time pre-render is reused (`prerendered=true`, 4 of 4) after a second correction: the chooser fingerprint was recomputed only when the per-update cache-key check fired, so a settle in sub-quarter-item steps left it on a mid-settle position. Tap-up to shown frame fell from 510-570 ms to 151-190 ms. It is still **not under 150 ms**: `activate_generation` is 140-174 ms, of which `commit_phase` is 72-109 ms and `swap_pointer` 26-33 ms, and the activate round trip is 280-326 ms. **Follow-up 2026-10-12 (b), task stays open** (same README, "Follow-up: what the tap waited on"; candidate `hl4bca4g`, two cold-process trials, injected taps): the touch-up handler no longer draws the picker before the optimistic check, Home's repaint waits for the commit, and a reused optimistic commit is acknowledged on arrival. Tap-up to the shown frame is **27.5-34.2 ms** (`prerendered=true`, 4 of 4). The durable activation is still not under 150 ms: `activate_generation` 167-179 ms (`commit_phase` 105-116 ms, shell commit exchange 37-53 ms), round trip 333-342 ms. Whether "end to end" means the visible apply or the helper's durable activation is for the coordinator to decide; until then the task stays open. Installed as system `298a2bf6` (README, "Install of candidate 8"); one installed apply trial: shown 28.0 and 42.2 ms, `activate_generation` 152.0 and 148.7 ms, round trip 313 and 309 ms. **Accepted 2026-10-12 by the operator** on installed system `298a2bf6…` (Rust `hl4bca4g…`), after being asked whether "end to end" means the visible apply (28-42 ms) or the helper's durable activation (149-179 ms): "it's great now i tried it all it's good". The operator measure is the visible apply; the durable activation figures above stand as recorded and are not claimed under 150 ms. Proof for 6.1-6.5: the tests named above, all passing on this host; proof for 6.6 is the reserved board, not run here. ## 7. Optimistic Apply: show an already-prepared generation ahead of the durable commit - [x] 7.1 `nix/rust-shell-client/src/main.rs` gains `should_apply_ optimistically` (pure: is the just-submitted `Activate` request's own target generation exactly the local `AppearanceReceiver`'s own `prepared` snapshot) and `optimistic_apply_due` (pure: has this exact `pending_id` already been checked this Apply), checked once per fresh Apply right after Wayland event dispatch in `serve`'s own loop -- the same tick a touch-up calling `theme_action(ThemeIntent::Apply)` would have run in. When eligible, `show_theme_optimistically` renders the already-validated prepared snapshot through the same `draw_wallpaper`/ `draw`/flush calls the real commit path already uses (skipping only for a video-backed selection, an unrenderable snapshot, or an overlay not immediately ready for a new frame -- each of which the real commit event's own existing readiness handling still covers correctly), and logs `rust-shell ms optimistic-apply shown ms=`. This never touches `AppearanceReceiver`'s own `prepared`/`active` bookkeeping, the `Activate` request `submit_theme` already dispatched to `ThemeWorker` unchanged, or the wire acknowledgement contract in any way -- the real `AppearancePhase::Commit`/`Rollback` handling (unmodified) is what authoritatively settles both the receiver's own state and the visible appearance once the durable transaction actually completes, which is also what correctly reverts the display and (via `ThemeView::accept`'s existing, unmodified `Err` handling) surfaces a visible error on a durable commit failure. A cold/unprepared generation is unaffected: it is never prepared early to qualify, and Apply shows the pre-existing busy state exactly as before. Verify with `cargo test --offline` (`route_tests::optimistic_apply_fires_only_for_an_activate_matching_the_ prepared_generation`, `route_tests::optimistic_apply_due_is_scoped_to_ each_fresh_pending_id_for_a_rapid_double_apply`, and `theme_ui::tests::a_failed_activate_clears_pending_and_surfaces_a_ visible_error`). - [x] 7.2 `nix/card-shell/appearance.c` gains an additive `"show"` protocol phase: renders `service.candidate` immediately when it is already `service.prepared` and matches the requested id/path, exactly like the existing `commit` branch's own render call and with the same best-effort restore-on-failure as that branch, but never touches `service.prepared`/ `service.candidate_path`/`service.current` -- so a real `commit` or `rollback` for the same candidate behaves identically whether or not `show` was ever sent. `main.rs`'s `show_appearance_optimistically` sends this best-effort, fire-and-forget (a short write deadline, no reply read -- any failure is silently ignored) to `K230_CARD_APPEARANCE_SOCKET` (defaulting to `nix/shell.nix`'s existing `SWAY_K230_CARD_APPEARANCE_ SOCKET` production path), *after* its own local render/log so a slow or unreachable compositor never inflates the logged tap-to-frame latency. This channel is only reachable once the two-phase transaction's own `--rust-socket`/`--deck-socket` fanout is wired into `theme-helper. service` (a separate, not-yet-landed task -- see `proposal.md`'s own note); until then it is dormant, correct, and tested in isolation. Verify with `python3 -m unittest tests.test_card_shell_appearance` (adds `test_show_renders_a_prepared_candidate_without_disturbing_the_ two_phase_state`, `test_show_is_rejected_for_a_generation_that_was_ never_prepared`, and `test_show_never_blocks_a_later_rollback_from_ restoring_the_previous_generation`, all against the real compiled `appearance.c` over a real socket). - [x] 7.3 OpenSpec: the delta spec gains the new "A warm Apply shows the new appearance ahead of the durable commit" requirement (five scenarios: warm Apply shows next frame, durable success settles with no further visible change, durable failure rolls back visibly with an error, a cold theme is unaffected, a rapid second Apply is scoped independently). `proposal.md`'s "What This Does Not Do" section is revised: its former "Two-phase transaction reordering" bullet read as ruling out any such change permanently, which stopped being accurate once the user approved this; it now explains precisely what did and did not change (the chooser's own display timing, never the wire acknowledgement contract). `design.md` gains a new "Optimistic Apply" section recording why gating on "already prepared locally" is sufficient for correctness, why the compositor side is best-effort rather than a precondition, and the two alternatives rejected (relaxing the wire ack contract itself; a cross-process optimistic-shown flag). Verified with `openspec validate the-shell-swaps-themes-without-a- python-stall --strict`. - [x] 7.4 Board re-check (coordinator, master `4b947d13`, installed as `i3dxi8y0...`): **the optimistic path did not fire.** No `optimistic-apply shown` line anywhere in the journal for an Apply of a neighbour-warmed, then explicitly confirmed, theme; the durable path alone still ran (`activate ... ms=441`, helper `activate_generation= 356.6ms`). This is task 7.5's own starting point, below -- see its grounding comment for the exact log sequence and root cause. - [x] 7.5 Root-cause and fix for 7.4: `ThemeView::poll_prepare_ahead` never checked `self.pending`, so while a real, explicit request (a confirm tap's own `Preview`, submitted via `submitted()`) was still awaiting its reply -- the page stays `List` until that reply lands, so `main.rs`'s own `page == ThemePage::List` gate did not stop it either -- the same per-tick call could *also* drain `pending_neighbor_warms` and submit an unrelated neighbour's own warm-up `Preview`. `ThemeWorker` processes requests strictly in submission order on one thread, and each appearance receiver keeps only a single most-recently-`prepare`d candidate; the neighbour's later "prepare" therefore silently overwrote what the confirm's own "prepare" had just staged in `AppearanceReceiver::prepared` -- before its own reply was even back -- so by the time Apply ran, `appearance.prepared()` named a different generation than the one just confirmed, and `optimistic_apply_skip_ reason` correctly (if unhelpfully, before this task) returned `generation-mismatch`. Fix: `poll_prepare_ahead` now returns `None` unconditionally whenever `self.pending.is_some()` -- no dwell-driven warm-up, no neighbour-queue drain -- resuming on whatever tick `pending` next clears; the dwell clock keeps accumulating in the meantime, so nothing already waited out is lost. Also adds a debug log, `rust-shell ms optimistic-apply skipped reason=`, on every Apply tap that does not show optimistically (`not-prepared`, `generation-mismatch`, `commit-draw-in-flight`, `video-background`, `unrenderable-snapshot`, `not-ready-for-a-frame`, `draw-wallpaper- failed`, `draw-failed`, `flush-failed`), via the new pure `optimistic_apply_skip_reason` (`should_apply_optimistically` now delegates to it). Verify with `cargo test --offline` (adds `theme_ui::tests::a_pending_confirm_pauses_every_warm_up_until_its_own_ reply_lands` -- reproduces the exact board sequence: warm neighbour A, swipe to and confirm neighbour B, assert nothing is emitted for any number of ticks while B's own Preview is pending, confirm Apply then targets exactly B's own reported generation; confirmed this test fails without the fix -- `left: Some((2, Preview {...})) right: None` -- and passes with it -- and `route_tests::optimistic_apply_skip_reason_names_ the_specific_cause`). Proof for 7.1-7.3, 7.5: the tests named above, all passing on this host (7.5's own regression test independently confirmed to fail against the pre-fix code); 7.4 is the board result quoted above, negative before this fix, not re-run since (board not touched by this task). - [x] 7.6 Board re-check of 7.5 (coordinator, master `2b575dba`, installed as `nmdf96j1...`): the race is fixed and the decision is reached, but **two new problems**: (a) `optimistic-apply skipped reason=not-ready- for-a-frame` -- the same class of bug `cd36242b` fixed for `draw_wallpaper()`'s own entry guard and the durable commit path's `ready` predicate, but `draw()`'s own entry guard (the theme chooser's own overlay/settings surface) still gated on `self.frame_pending`, and so did `show_theme_optimistically`'s own separate readiness pre-check (copied from the durable path before this task noticed the durable path's own `overlay_ready` term had the identical gap); (b) about 300 ms from the Apply touch-down to the optimistic decision, not yet root-caused with board timing data. - [x] 7.7 Fix for 7.6(a): a new pure `redraw_entry_ready(appearance_ pending, configured, layer_present)` (`main.rs`) is `draw()`'s entry guard now, with no parameter to gate on any surface's own outstanding frame callback -- structurally the same fix `cd36242b` already applied to `draw_wallpaper()`, extended to the overlay surface, whose own entry guard that earlier fix never touched. `show_theme_optimistically`'s own separate `overlay_ready`/`ready` pre-check is removed entirely: it now attempts `draw_wallpaper()`/`draw()` directly and reads their own return values, exactly like the durable commit path's `ready == true` branch already does (with no 1400 ms retry window, since unlike a durable commit, a missed optimistic frame costs only the optimism itself -- the real commit/rollback event still lands and settles correctly regardless). The durable commit path's own `overlay_ready` term (`serve`'s own `pending_appearance` handling) is fixed the same way, closing the identical latent gap there too (previously invisible behind its own 1400 ms retry loop). Verify with `cargo test --offline` (adds `route_tests::redraw_entry_never_gates_on_a_surfaces_own_ outstanding_frame_callback`, whose signature -- no frame-pending parameter at all -- is itself the regression proof: this mistake cannot be reintroduced by accident). - [x] 7.8 Instrumentation for 7.6(b): every `optimistic-apply skipped reason=...`/`shown` log now carries `ms=`, so a board re-run can attribute the remaining latency precisely (input recognition before `submit_theme` sets `theme_apply_tapped_at` -- already close to the existing `touch-up ` log, since `submit_theme` runs synchronously in the same call chain -- versus this task's own decision/draw/flush cost, which reading `main.rs`'s code found no blocking calls in: `try_submit` is `try_send` (never blocks), `AppearanceSnapshot::clone()` carries no pixel data, and (after 7.7) the draw calls no longer wait on any frame callback). Closeout audit (2026-09-28): the `ms=` fields (`nix/rust-shell-client/src/main.rs` lines ~1600-1749) are on disk and were exercised by the very next board run, quoted verbatim in task group 8's own grounding comment above (coordinator, master `e2ba1d7e`, installed `s376wb46...`: `optimistic-apply shown ms=222.7`) -- this instrumentation's own board data is what root-caused task 8's synchronous-overlay-rebuild finding. Ticked here because that board run already exists and is cited, not because a further run was performed by this audit. Proof for 7.1-7.3, 7.5, 7.7: the tests named above, all passing on this host; 7.4/7.6 are the board results quoted above; 7.8's own instrumentation answered its own question on the very next board run -- see task 8 below, which its board evidence directly motivates: `optimistic-apply shown ms=222.7` is (almost) entirely the synchronous overlay-scene rebuild Apply's own optimistic show pays inline, not event-loop or dispatch latency. ## 8. Pre-render at prepare time; reuse the durable commit's own frame - [x] 8.1 A third wallpaper buffer: `draw_wallpaper()`'s own buffer-pool exhaustion check raised from `>= 2` to `>= 3` (the overlay surface's own pool was already bounded to 3), and the SHM pool's own initial-size hint raised from `* 5` to `* 6` slots (568x1232x4 bytes each, ~2.8 MB, matching the coordinator's own figure) -- an estimate only, since `SlotPool` itself grows automatically on any further allocation regardless (confirmed by reading `smithay-client-toolkit`'s own `SlotPool::resize` doc: "the pool automatically resizes when you allocate new slots"). This alone (independent of pre-rendering) is what stops the durable commit's own redraw from ever again failing merely because an optimistic draw is still holding a buffer the compositor has not yet released. - [x] 8.2 `RendererCache` (`render.rs`) gains `content_generation: u64` (bumped by `set_theme_view`/`set_services`/a real `set_drawer_pressed` change/a `poll_theme_image`- or `poll_theme_thumbnails`-detected change -- every non-appearance input a scene render depends on -- and deliberately *not* by `set_appearance`/`set_icon_theme`, which a candidate pre-render is expected to differ from `self.theme` on, on purpose), `render_candidate_overlay(theme, route, width, height, apps)` (computes a full overlay-scene raster for an arbitrary `theme` using a *fresh*, isolated `IconCache` and every other input read live from `self` -- `services`/`chooser`/`pressed`/`preview_surface`/ `preview_error`/`thumbnails` -- provably never touching `self.theme`/ `self.icons`/`static_pixels`/`route`, so nothing can flash the wrong theme before Apply), and `adopt_prerendered_overlay(theme, route, width, height, pixels)` (seeds `self.theme`/`self.icons`/ `static_pixels`/`route`/`width`/`height`/`scroll` directly from an already-computed raster, so `draw()`'s own next call sees a cache hit and takes its cheap shift-and-copy path instead of rebuilding). Verify with `cargo test --offline --lib render::tests` (adds `content_generation_bumps_on_content_changes_but_not_on_appearance_ changes`, `render_candidate_overlay_never_mutates_the_live_cache`, `adopt_prerendered_overlay_is_a_cache_hit_with_the_candidates_own_ pixels`). - [x] 8.3 `main.rs`'s own event loop computes the pre-render once per (generation, route, geometry, `content_generation`) tuple whenever the Preview page's own currently-loaded candidate is already the receiver's own `prepare`d snapshot (the same precondition Optimistic Apply itself checks -- never a cold generation, never a merely-warmed neighbour still being browsed past), bounded to one slot (`ShellClient:: prerendered_overlay: Option`), and only *after* that tick's own `queue.flush()` -- never earlier in the same iteration -- so the real, synchronous Cairo cost this pays is never charged against flushing the very Preview-page frame that made it eligible. Skipped entirely while an Activate is already in flight. Verify with `cargo test --offline --lib route_tests` (`prerendered_overlay_matches_requires_every_field_to_agree`, the pure matching predicate this trigger and Apply-time consumption both share). - [x] 8.4 `show_theme_optimistically` consumes the stored pre-render (always taking the one bounded slot, matching or not, so a stale one never lingers for a later Apply) when it matches this exact Apply's own generation, route, geometry, and `content_generation`; on a match it calls `adopt_prerendered_overlay` instead of `set_appearance` (skipping the rebuild entirely); on any mismatch it falls back to `set_appearance` exactly as before this task. The `optimistic-apply shown ms=...` log gains `prerendered=true/false`. Verify with the 8.2/8.3 tests above (the underlying primitives) plus `route_tests:: optimistic_apply_fires_only_for_an_activate_matching_the_prepared_ generation` and the existing Optimistic Apply suite, all still passing unmodified. - [x] 8.5 The durable commit reuses an already-shown Optimistic Apply frame instead of redrawing it: `ShellClient::optimistic_active: Option` records the generation a successful optimistic show just flushed; the very next `Commit`/`Rollback` event's own handling checks `may_reuse_optimistic_frame` (pure: true only for a `Commit` whose own generation matches exactly -- a `Rollback`'s own target is by definition the *previous* generation, never the one just shown) and clears `optimistic_active` unconditionally either way, so it can only ever be read by that one next event. A match skips `draw_wallpaper`/ `draw`/`flush` entirely (`pending_appearance`'s own new `reuse_optimistic` element) and goes straight to the same video-state bookkeeping (`adopt_committed_video_state`, factored out of what was previously inline in the redraw-success branch so both paths share it) and the ack -- still sent only after a real, already-flushed frame, never before one. Verify with `cargo test --offline --lib route_tests` (`may_reuse_optimistic_frame_only_for_a_commit_matching_exactly`). - [x] 8.6 Board re-check: confirm `optimistic-apply shown ms=` drops close to the ~30 ms target (a cache-hit shift-and-copy plus an attach+commit, not a rebuild) with `prerendered=true` for a warm Apply; confirm no `appearance-commit-rejected draw-wallpaper-failed` on the success path (the third buffer); confirm the durable commit's own log shows `appearance-commit-accepted reused=optimistic` and `activate_generation` no longer carries a retry's own doubled cost; confirm a geometry, page, or content change between prepare and Apply correctly falls back to a full rebuild (`prerendered=false`) rather than showing stale content. Closeout audit (2026-09-28): this recheck was in fact run -- it is task group 9's own grounding comment above (coordinator, master `c6f0237a`, installed `vq6vkvqz...`). Following this project's own established convention for "board re-check" tasks (7.4 and 7.6 above are ticked despite negative results, because finding and reporting the real cause is the deliverable), ticking here too: the result was `prerendered=false` (not the target), root-caused to `submit_theme`'s own `theme_dirty()` invalidating the very pre-render it needed, which task group 9 then fixed; the durable-commit-reuse half of this check did pass (`appearance-commit-accepted reused=optimistic`, no rejection). The unmet target is what task group 9 exists to close, not evidence this recheck itself was skipped. Proof for 8.1-8.5: the tests named above, all passing on this host; proof for 8.6 is the reserved board, not run here. ## 9. Split the footer/status from the cached body so a pre-render can actually match at Apply - [x] 9.1 The Preview page's dynamic Apply/Cancel footer (the pressed highlight, the Apply button's own text/color/busy spinner across idle/ applying/applied, and the `Preparing…`/error/message status line below it) is extracted out of `paint_theme_chooser` into a new, standalone `paint_preview_footer_status` (`render.rs`) and painted live, by `RendererCache::draw()` itself, on every call -- whether that call just rebuilt `static_pixels` or reused it (including an adopted pre-render). `paint_theme_chooser` no longer paints any of it at all (for either page: the List page's own status line is unaffected, still baked into its cached body as before). This is what makes a pre-render computed before an Apply tap still correct *after* one: the tap's own `pending` transition changes only what the live overlay paints, never what the cached/pre-rendered body underneath it has to be rebuilt for. Verify with `cargo test --offline --lib render::tests` (adds `adopted_prerender_still_shows_the_live_pending_state_via_the_overlay`, independently confirmed to fail -- the exact stale-idle-button pixels -- with this task's fix temporarily disabled, and pass with it restored; all 25 pre-existing `render::tests` cases, which exercise the Apply footer's own pixels through the unchanged public `RendererCache::draw`, still pass byte-for-byte unmodified, proving the split is output-equivalent for every case that was already covered). - [x] 9.2 `content_generation` (task 8.2) no longer bumps for a `set_theme_view` call that changes only `pending`/`pending_id`/ `selection_error`/`error`/`message`/`pulse_phase` -- exactly the fields 9.1 moved to the live overlay -- via a new pure `theme_view_cache_key_ differs` (comparing `page`/`list`/`preview`/`theme_pressed`/ `background_pressed` exactly, `theme_position`/`background_position` with the same small tolerance `RendererCache::draw`'s own `scroll` check already uses). `invalidate()` itself stays unconditional on every `set_theme_view` call, so a live (non-optimistic) `draw()` still rebuilds on every theme_view change exactly as before this task; `content_generation` is now a narrower, pre-render-specific freshness signal, decoupled from that. This also closes the coordinator's own third observation (double pre-render computation ~150ms apart, a full `THEME_PULSE_INTERVAL`): a pulse-only tick can no longer churn `content_generation` either. Verify with `cargo test --offline --lib render::tests` (adds `a_pending_only_theme_view_change_does_not_bump_ content_generation`, proving both that pending/pulse changes are now excluded and that a genuine content change -- a different preview -- still bumps it as before). - [x] 9.3 The pre-render trigger (task 8.3) now requires `theme_view. pending.is_none()` outright, not just "not an Activate": the background carousel's own busy spinner (a `Preview{background_id: Some(_)}` request in flight) is baked into the cached body the same as anything else `content_generation` does not track, and 9.1's live overlay does not cover it (only the Apply/Cancel footer and status line) -- so computing a pre-render while a background selection is still pending risked caching a stale busy indicator no later live paint would ever correct. Requiring nothing at all pending closes that gap by construction rather than adding a second live overlay for it. - [x] 9.4 `optimistic-apply shown ms=... prerendered=false` now carries `reason=` (`no-prerender-computed`, `generation`, `route`, `geometry`, `content-changed`, or `not-on-settings-route`) naming exactly which check failed, via a new `prerendered_overlay_mismatch_ reason` that `prerendered_overlay_matches` (task 8.3's own pure predicate) now delegates to, so the two can never drift apart. Verify with `cargo test --offline --lib route_tests` (`prerendered_overlay_mismatch_reason_names_the_specific_field`). - [x] 9.5 Board re-check: confirm `optimistic-apply shown ms= prerendered=true` for a warm Apply, with `` close to the ~30 ms target; confirm the earlier double-prerender-computation symptom is gone (at most one `optimistic-apply prerendered` line per genuine content change while dwelling on the Preview page); confirm the screen's own footer text still reads `Applying…` then `Theme applied`/ `Apply again` correctly across the transaction, never stuck on a stale `Apply`; if `prerendered=false` still occurs, its own `reason=` field states why directly. Closeout audit (2026-09-28): this recheck was run -- it is task group 10's own grounding comment above (board re-check of task 9, master `644cd061`, installed `n44hk9js...`): a *matched* pre-render took 161ms touch-up-to-commit (target ~30ms), and the double-prerender symptom was still present, ~154ms apart, for the same generation. Ticked per the same "recheck ran and reported" convention as 7.4/7.6/8.6 above: the result was short of target and the double-prerender symptom was not yet fixed, which is exactly what motivated task group 10's tap-to-apply redesign (10.4 root-causes the double-prerender to the removed Preview page's async still-image decode). The footer-text wording check was superseded by 10.2's removal of the Apply/Cancel footer entirely, not separately confirmed here. Proof for 9.1-9.4: the tests named above, all passing on this host (9.1's own regression test independently confirmed to fail against the pre-fix code); proof for 9.5 is the reserved board, not run here. ## 10. Tap-to-apply: no separate Preview page, no Apply/Cancel step - [x] 10.1 `theme_ui.rs` rewritten: `ThemePage::Preview` removed (only `Controls`/`List` remain -- the theme carousel, the active theme's own background carousel below it, and a plain "Current theme"/"Current background" label under whichever slice of each is centred and durably active, all on one page). A new `Desired`/`ThemeView::advance` state machine (`desired`, private-but-`pub`-for-struct-update, same convention as `prepare_ahead_watch`) coalesces rapid taps: a fresh tap always overwrites `desired`; `advance()` submits the next step toward it only when nothing is already in flight; a reply that no longer matches `desired` is discarded, and the caller's own follow-up `advance()` call immediately moves on to whatever is now desired -- bounding in-flight work to one request, with the documented tradeoff that a second rapid tap's own visible effect is bounded by the first tap's own in-flight round trip, not instantaneous (proven directly: `rapid_taps_across_themes_coalesce_onto_the_last_one`). New `ThemeView:: tap_theme`/`tap_background` replace `preview_request`/`background_request`/ `apply_request`; `ThemeIntent::Apply` and `ThemeView::selection_error`/ `selection_failed` are removed. The original implementation incorrectly sent a background selection directly to `Activate` with the previous background's generation. Task 16 corrects this: both theme and background taps prepare the target selection first, then activate its generation. New `known_generations: HashMap` remembers a warm-up reply's own `(theme_id, generation)` (via `record_known_generation`, wired from `main.rs`'s `theme_reply` on a `prepare_ahead_reply` hit) so the pre-render trigger (10.3) can target a centred-but-not-tapped theme without its own fresh round trip. New `ThemeView::applying_theme_index`/`applying_background_index` drive a brief, in-place busy spinner on the tapped slice (`render.rs:: paint_carousel`'s existing `busy: Option` parameter, unchanged) instead of blocking the carousel. Verify with `cargo test --offline --lib theme_ui::tests` (24 tests, all passing, including tap-apply of a cold theme, tap-apply of the already-active theme, background tap-apply, the video/already-selected background no-ops, rapid-tap coalescing, and rollback-on-failure with a visible error). - [x] 10.2 `render.rs`'s `paint_theme_chooser` merges the old List and Preview branches into one: the theme carousel, its "Current theme"/ origin label, the "Backgrounds" heading, the background carousel, its own "Current background"/"Tap to apply" label, then one pending/error/ message line -- all on the fixed layout `theme_ui::THEME_CAROUSEL_TOP` (moved from `204.0` to `132.0`) and `BACKGROUND_CAROUSEL_TOP` (now a const-expr `THEME_CAROUSEL_TOP + THEME_GEOMETRY.expanded_h + 100.0` = `872.0`, not board-verified for fit/spacing -- see this task's own evidence). `paint_preview_footer_status` (the Preview page's own live Apply/Cancel footer overlay, task 9.1) and `RendererCache::draw`'s own call to it are removed outright: with no Apply button, nothing needs a second, live paint pass any more -- `set_theme_view`'s own unconditional `invalidate()` (task 9.2's own doc) already forces the ordinary rebuild path to repaint the busy spinner and pending/error/message line fresh on every `ThemeView` change, and the one case that skips a fresh rebuild (an *adopted* pre-render) is correct exactly because, at the moment it is shown, the applied theme already looks done -- proven by `a_theme_view_change_after_adopting_still_rebuilds_and_shows_pending` (renamed/re-reasoned from 9.1's own regression test) and `a_pending_only_theme_view_change_does_not_bump_content_generation` (re-targeted to the one List page, doc updated). The single large "screen crop" still-image preview (`RendererCache::poll_theme_image`/ `preview_surface`, `theme_preview_image_pending`) is now a deliberate, documented permanent no-op -- both carousels already show their own thumbnails via `ThemeThumbnailCache`, so nothing paints a decoded still any more; `ThemeImageWorker`/`ThemeImageKey` and the worker's own two tests are kept, unused, as a named follow-up to prune rather than a silent removal. Verify with `cargo test --offline --lib render::tests` (all passing, including the fixed `list_and_background_rows_paint_their_own_thumbnail_once_decoded`, which caught a real bug this task introduced and fixed in the same commit: the merged `poll_theme_thumbnails` match arm used to `return` early when `chooser.list` was `None`, skipping the background carousel's own thumbnail requests whenever its own detail loaded without a list -- the two are now requested independently). - [x] 10.3 `main.rs`'s touch-down/motion/up/tick handlers no longer branch on `ThemePage::List`/`Preview` to pick a carousel: both carousels are probed independently now that both are visible at once -- `Carousel::down`/`motion`/`tick` are called unconditionally (each is keyed by its own armed `contact.id` internally, safe to call on both; `tick` has no touch-ownership concept at all and always advances both), and `Carousel::up` is tried on the theme carousel first, falling through to the background carousel only on `None` (a touch id it never armed), which correctly finds whichever one, if either, owns a given touch. `theme_reply` now compares `theme_position`/`background_position` before and after `accept()` to decide which physics carousel (if either) to re-index, instead of branching on page -- precise, since only `accept()`'s own List/settling-Preview handling ever changes those fields, never a concurrent drag. The pre-render trigger (task 8.3) is retargeted from the removed Preview page's own loaded candidate to the theme carousel's own *centred* candidate on the List page, using `known_generations` (10.1) rather than a fresh Preview round trip, so pre-rendering the very next likely tap-apply never itself costs a round trip. `show_theme_optimistically` gained per-stage `ms=` instrumentation (`optimistic-apply stage adopt_ms=... wallpaper_ms=... overlay_ms=... flush_ms=...`, logged alongside the existing summary line) -- the coordinator's own ask, board evidence 2026-09-28: a *matched* pre-render still took 161ms touch-up-to-commit, target ~30ms. This instrumentation is coarse (one line per `draw_wallpaper`/`draw` call, since attach/damage/`wl_surface::commit` all happen inside those calls and are not separately timed); a finer breakdown inside `draw`/ `draw_wallpaper` themselves, and the wallpaper-buffer-attach optimization the same board evidence asked for (pre-build the actual `wl_buffer` at prepare time so adopting is attach-only, no pixel copy), are **not attempted this task** -- named as deferred follow-up work in this task's own evidence doc, since implementing and tuning either without a way to measure them on the actual board (this task did not touch the board) risks a change that looks right in a diff and is wrong, or merely neutral, in practice. Verify with `cargo test --offline` (`route_tests`, 14 tests, unchanged and passing -- `should_apply_ optimistically`/`optimistic_apply_due`/`may_reuse_optimistic_frame`/ `prerendered_overlay_matches` are pure functions this task did not touch, confirming the optimistic-apply/pre-render machinery's own correctness is intact under the new single-page model). - [x] 10.4 Investigated the coordinator's third observation (the same generation's own pre-render computed twice, ~154ms apart) by code review rather than board access. On master `644cd061` (before this task), the Preview page's own `RendererCache::poll_theme_image` was actively decoding the selected background's "screen crop" still image asynchronously; that decode completing bumps `content_generation` (its own `changed` branch) independently of `theme_view_cache_key_differs` (task 9.2's fix only excludes `ThemeView` fields, not this separate decode-completion signal) -- a ~150ms async decode completing shortly after the Preview page loads exactly matches the reported ~154ms gap and a "computed twice for the same generation" symptom (the second compute reacting to the image arriving, not a fresh tap). This task's own 10.2 makes `poll_theme_image` a permanent no-op (nothing paints a decoded still any more, both carousels use `ThemeThumbnailCache` instead), which removes this specific trigger structurally: there is no longer an asynchronous decode running that could bump `content_ generation` a second time while a person dwells on an unchanged candidate. This is reasoned from the code, not re-verified against fresh board evidence this session (the reserved board was not used, per this task's own constraint); 10.6's board re-check is where that gets confirmed or refuted. - [x] 10.5 OpenSpec: added "Tapping a theme or background applies it immediately, with no separate preview-then-Apply step" to `specs/ runtime/shell-themes/spec.md` (this change's own delta -- the capability has not been archived into `openspec/specs/` yet, so every requirement here, including this one, is still `ADDED`), with the user's verbatim decision as its grounding, and lightly reworded "A warm Apply shows..." to note that "Apply tap" now means a tap on either carousel's own centred slice, not a since-removed button -- the mechanism that requirement describes is unchanged. `openspec validate the-shell-swaps-themes-without-a-python-stall --strict` passes. - [x] 10.6a `tests/rust_theme_chooser_qemu.py` rewritten for the one-page flow: opens the chooser, waits for the active theme's own detail to auto-load (no tap needed), browses the theme carousel by drag and by a side-slice recentre-tap (still never applies), confirms a warmed theme applies on a single centred tap with no further tap (screen repaints, exactly one `activate` for it, `--expected-generation` correct), browses and tap-applies a background of that theme directly (`activate` with `--background`, and specifically asserts no `preview` call was needed for it), then proves rapid-tap coalescing: `THEME_COMMAND` sleeps 1.5s before answering a `preview` call for one designated theme (`K230_TEST_THEME_SLOW_ID`), giving a reliable window to tap a *different* theme while the first is in flight; asserts the superseded theme's own request was genuinely sent but never activates, and the second tap's theme does. Written and syntax-checked; not yet run to a real result (10.6b). - [x] 10.6b Run 10.6a's script to a real PASS. Attempted twice this session (patched, IPC-capable `sway-unwrapped`, built after finding `.#nixosConfigurations.k230.pkgs.sway-unwrapped` is plain upstream sway without the `card_shell` commands this harness needs -- the correct binary is a side effect of `.#card-shell`, at its own `swaybar`/`swaymsg`/`swaynag` symlink target). Both attempts failed at `k230-shell-rust: route timed out` (`main.rs::request`'s pre-existing 500ms socket-round-trip deadline for the `--surface` helper invocation) *before* reaching any theme-chooser-specific code -- confirmed via `git diff 80817e3d..HEAD -- main.rs` to be outside this task's own diff, and the shared host was running several other concurrent agents' own builds and at least one other `qemu-riscv64- static` session at the same time, so contention rather than a code regression is the leading explanation. Not chased further by loosening a timeout this task does not own. See this task's own evidence doc for the full account. Closeout audit (2026-09-28): PASS under QEMU with a synthetic backend -- `python3 tests/rust_theme_chooser_qemu.py --sway --rust <.#handheld-shell-rust> --output /tmp/q106`, recorded with exact store paths, the synthetic command log and capture hashes in `docs/evidence/theme-picker/tap-to-apply-qemu/README.md`. QEMU proof only; panel fit and on-glass behaviour remain 10.7/14.4. - [x] 10.7 Board re-check: confirm the one-page tap-to-apply flow reads and behaves correctly in dark and light themes on the actual panel; confirm `optimistic-apply stage adopt_ms=... wallpaper_ms=... overlay_ms=... flush_ms=...` for a warm tap-apply, and whether the total (`optimistic-apply shown ms=...`) has moved from the 161ms board baseline toward the ~30ms target now that the double-pre-render trigger is structurally removed (10.4); confirm the new `THEME_CAROUSEL_TOP`/`BACKGROUND_CAROUSEL_TOP` layout (10.2) actually fits the 1232px panel without clipping or crowding. Needs the reserved board; not run by this task. **Re-checked 2026-10-12, two cold-process trials (injected taps, journal, native grim captures; not a camera view of the glass):** both bundled dark (catppuccin) and light (catppuccin-latte) read correctly and the picker fits the 568x1232 panel with the "Theme applied" line about 27 px above the bottom edge, not clipped. `optimistic-apply shown ms` was 201.2/204.4 (light) and 156.1/163.2 (dark); stage adopt/wallpaper/overlay/flush for the warm taps ranged 0.4-0.5 / 2.6-11.5 / 55-86 / 0-0.9 ms. It has **not** moved from the 161 ms baseline toward ~30 ms; reason on every tap: `prerendered=false reason=content-changed`, so the browse-time pre-render is discarded; the frame appears about 510 to 570 ms after touch-up because a tap-time `preview` (255 to 288 ms) runs first. The original dark generation was restored (hash identical before and after). Evidence: `docs/evidence/theme-picker/board-session-2026-10-12/README.md` (section "Task 10.7 and 6.6"). Proof for 10.1-10.3, 10.5, 10.6a: the tests and commands named above, all passing/PASS on this host; proof for 10.4 is code review, named as such, not board evidence; 10.6b and 10.7 are not run/not obtained this task. The wallpaper-buffer-attach optimization and a finer per-stage attach/damage/commit breakdown (both asked for alongside 10.3) are deferred follow-up work, not attempted this task -- see 10.3's own doc. Keep this change open (or split at review time into an explicit successor per `AGENTS.md`) until 2.3, 3.4, 6.6, and 10.7 have results; 3.3b is named here so it is not silently dropped or claimed done without a board result. Task 5.4's board result is recorded above (board-chooser-2026-09-25.md). 9.5 was resolved above (its board recheck already ran, as task group 10's own grounding comment). Closeout audit (2026-09-28): the note above pre-dates task groups 11-16. The full current gate list after this audit is: 2.3 (board), 3.3b (host-doable, deliberately not attempted here -- touches a load-bearing two-phase-transaction return contract; see 3.3b's own text), 3.4 (board, depends on 3.3b), 6.6 (board), 10.7 (board/panel), 11.3 (board, depends on task 13's harness), 13.1-13.3 (host-doable but a substantial new profiling/gating harness, not attempted in this audit -- see `docs/closeout/themes-audit.md`), 13.4 (board, depends on 13.1-13.3), 14.4 (real-finger acceptance specifically, not injected), 15.2 (partial; compositor-stage coverage is host-doable, the physical capture is board), 15.3 (board). 7.8, 8.6 and 9.5 are now ticked above with citations to existing board runs already quoted as grounding comments in this file. ## 11. Stop repeated picker thumbnail work and prioritize swipes The user reports slow swiping and apparently repeated theme loading on the installed `926c7a61` Rust shell. Source review identifies an over-capacity combined thumbnail request set and speculative main-thread pre-render during motion; neither is yet a measured explanation of this board report. - [x] 11.1 Bound the combined theme/background thumbnail working set to viewport-relevant entries, preserve centered-first scheduling, and use the same set for pending-work checks. Keep the existing cache bound and avoid stale in-flight results evicting current visible entries. Add a host regression with old stationary demand greater than 36 entries which warms without continual requeues and remains bounded while browsing. Proof: `cargo test --offline --manifest-path nix/rust-shell-client/Cargo.toml --lib theme_picker` and `cargo test --offline --manifest-path nix/rust-shell-client/Cargo.toml --lib theme_thumbnails`. - [x] 11.2 Gate speculative overlay pre-render while either carousel is dragging/coasting/settling, with a test proving it resumes at rest. Proof: `cargo test --offline --manifest-path nix/rust-shell-client/Cargo.toml --bin k230-shell-rust theme_prerender`. - [x] 11.3 On the reserved board after kernel work, record the exact Rust store path and catalog/background counts; compare cold/warm chooser opening, swiping across several themes and back without applying, and 15 seconds idle. Capture native video plus touch/frame journal timing and Rust/helper CPU deltas. Confirm the active generation stays unchanged during browsing, repeated loading settles, and warm swipes improve without a memory increase. Extend the existing `tools/theme-swap-jank.py` capture for this gesture sequence if necessary; its current activate-only run is not swipe proof. Commit the result before checking this task. Physical proof remains pending; host tests alone do not satisfy it. Closeout audit (2026-09-28): the one paired attempt at this run is `docs/evidence/theme-picker/working-set/ README.md`, which is explicit that it is "not a completed picker performance result" -- the new run's own background row never loaded ("Loading backgrounds" stuck), confounding the opening/memory comparison, and warm swipes were still slow (125-132ms median, worse than the old run's max). **Needs a fresh reserved-board run repeating this exact workload with the store-relocation lookup bug (task 12.1) already fixed underneath it**, which it was not for that attempt. **2026-10-12 board result, task stays open:** the fresh reserved-board run with 12.1 underneath was performed as task 13.4 (`docs/evidence/theme-picker/board-session-2026-10-12/README.md`). Both rows resolved (after applying a theme: a pointer-less board never loads the background row), the generation stayed unchanged, repeated loading settled and warm swipes improved in median, but the candidate uses about +6 MiB more Rust RSS (+8%) at the end of the warm idle window than the repaired baseline in all three recorder-off pairs, warm-swipe p95 did not improve, and repaint still dominates. Checking this box needs the memory increase explained or removed and the next correction named under 13.4 measured. **2026-10-12 (b) board result** (`docs/evidence/theme-picker/board-session-2026-10-12b/README.md`, "Task 11.3"; candidate `3m65ag9z` against the same repaired baseline `8cik`, three recorder-off pairs in alternating order, all gates passed): warm theme commit gap median 140 to 63 ms and p95 173 to 89 ms, background 84 to 38 ms, warm commits 59 to 97, Rust CPU in warm theme swipes 79 to 57%, idle unchanged. Memory: end-of-warm-idle RSS median +2.0 MiB (pairs +2.0, -2.3, +11.0; the +11 pair is a low baseline run, the candidate ends at 80-87 MiB in all three), and with the picker closed the candidate is below the baseline. The board's `picker-memory` log attributes the open-picker difference to the 2.0 MiB card cache and 1.5 MiB scratch surfaces, released when the picker closes. The 13.4 corrections are measured: row-local thumbnail damage, the cached slice repaint, and the RSS attribution. Installed as system `fm8gk6xi…` (ordinary boot `34d77814`). Host proof for 11.1–11.2 (2026-09-26): `--lib theme_picker` passed 1 regression; `--lib theme_thumbnails` passed 16 tests; the additional narrow `--lib thumbnail` filter passed 18 tests including the two existing renderer checks; `--bin k230-shell-rust theme_prerender` passed 1 motion-gate test. All used the offline Cargo command above. The 40-theme/40-background fixture has old demand of 68 variants; the 568-pixel viewport admits 24, submits each once, then performs 200 stationary polls without another request. It also settles after both rows move and after a wider viewport exercises admission. Additional fixtures cover stale in-flight replies, preservation of an oldest visible resident, and actual byte-budget eviction. These are deterministic host cache/channel tests, not timing or physical swipe evidence. No Nix build or board use was performed for these tasks; 11.3 remains open. ## 12. Keep an unchanged bundled theme selected after a system upgrade - [x] 12.1 Repair active catalog identification after only the Nix store package prefix changes. Require unique built-in role/name plus the saved source digest, retain exact-path priority, and leave active state unchanged. Add host tests covering relocation, changed content, duplicate origin, ambiguity and malformed reports. Proof: `python3 -m unittest discover -s tests -p test_theme_catalog.py`. - [x] 12.2 On the reserved board, open the chooser after installing the rebuilt theme package; verify the preserved active theme is centered and its background row resolves and responds, without activating anything. Commit native evidence and rerun the affected browse measurement before closing this gate. Task 11.3 remains open: the first paired run did not establish improved warm swipes, and the second lacked background content. Board proof for 12.2: `docs/evidence/theme-picker/store-relocation/README.md` now records verified normal installation, the saved theme centered, a populated background row responding to a drag, and the repeated 70.93-second browse measurement with unchanged generation. The initial image matches the earlier one-shot capture exactly. Both idle windows have zero redraws; warm swipe commit gaps remain slow (median 141 ms), so task 11.3 stays open. Host proof for 12.1 (2026-09-27): the exact unittest discovery command above passes 21 tests. Six added regressions cover an identical source relocated to a new store package after the old object is removed, unchanged active pointer and report, real list-to-preview background discovery without activation, changed source rejection, same-name user origin, ambiguous/malformed identity, and exact-path priority without hashing. The board confirmed a null active catalog ID with a preserved generation before this fix; native frames showed `Loading backgrounds`. The installed board proof above, rather than these host tests, closes 12.2. ## 13. Attribute remaining swipe cost and keep speculation out of movement The partial paired evidence is committed in `docs/evidence/theme-picker/working-set/README.md`. Task 11.3 remains open: held-touch pre-rendering stopped, but warm swipe improvement was not proved, and the second capture lacked the active background row. None of the tasks below is complete merely because that partial evidence or this plan landed. - [x] 13.1 Establish a matched-content profiling harness after task 12.2. Move the bounded browse workload into `tools/theme-picker-profile.py` with host fixtures in `tests/test_theme_picker_profile.py`; preserve the historical capture unchanged. Require resolved/visually reviewed theme and background rows, catalog counts, exact source/runtime identities, matching active generation, geometry and background playback state before treating runs as comparable. Record whether each opening is first-observed, verified process-cold, or warm; do not infer disk-cold or delete user cache. Capture both carousel swipes and 15-second idle windows without activation, using the verified virtual device and existing single-process injector. Planned host proof: `python3 -m unittest discover -s tests -p test_theme_picker_profile.py`. Fixtures must reject missing-row comparisons, PID reuse and generation changes, and must not count idle intervals or callback/commit mixtures as swipe frame gaps. Keep only allowlisted public fields and hashed private generation identities. **Done 2026-10-11:** `tools/theme-picker-profile.py` (stdlib only; subcommands `validate`, `preflight`, `capture`, `reduce`, `compare`) and `tests/test_theme_picker_profile.py` landed. `python3 -m unittest discover -s tests -p test_theme_picker_profile.py` passes 31 tests (host fixtures only; no board, injected event or camera evidence). Fixtures cover missing/unreviewed rows, store-identity and generation mismatches, PID reuse, Rust-clock restarts, generation change, activation, process-cold verification, idle/callback/wallpaper events excluded from swipe gaps, private-field allowlisting, and comparison gating. Board `capture` and the physical comparison remain task 13.4. - [x] 13.2 Add bounded, opt-in cost attribution to that harness and the Rust/helper paths. Sample per-thread CPU/start identity and process RSS, plus helper cgroup CPU so subprocess work is not hidden by daemon-only measurements. Count/time live scene rebuilds, thumbnail request/completion and hash/read/decode work, appearance-prepare handling, speculative request admission/completion and overlay pre-render separately. Correlate counters with monotonic gesture phases, distinguish CPU from wall time, and bound telemetry memory/output; instrumentation must be off by default. Extend the 13.1 host fixtures with synthetic overlapping phases and counter/PID discontinuities. Narrow Rust proof: `cargo test --offline --manifest-path nix/rust-shell-client/Cargo.toml --lib theme_picker`. Compare instrumentation-on/off overhead on the board before interpreting detailed timings; no native screencopy runs inside the timing pass. **Host/source part done 2026-10-12:** `nix/rust-shell-client/src/runtime_trace.rs` keeps fixed-name counters (count, wall, thread CPU, max wall) for 17 kinds bucketed by `rest`/`held`/`moving` gesture phase with a bounded phase log, still default-off (`K230_TRACE_PATH`; `K230_TRACE_COUNTERS_ONLY=1` drops the event ring); `main.rs`/`theme_thumbnails.rs` add speculative completion and thumbnail request/completion counts. `tools/theme-picker-profile.py` adds opt-in per-thread CPU with start-time identity, RSS, helper cgroup CPU, verified instrumentation mode, `reduce --rust-trace` and an `overhead OFF ON` comparison; 19 new host fixtures cover overlapping phases and counter/PID/thread-identity discontinuities. Proved: `cargo test --offline --manifest-path nix/rust-shell-client/Cargo.toml --lib theme_picker` and `python3 -m unittest discover -s tests -p test_theme_picker_profile.py` (50 tests). NOT proved: the on/off overhead comparison on the board, which is the first step of task 13.4's session; no board number or detailed timing is claimed here. Operator notes are in `docs/runtime-tracing.md`. **Board overhead measured 2026-10-12 (injected, installed `ybpn` ELF):** three recorder-off, three counters-only and one trace run of the same workload give about +0.08 percentage points of Rust CPU when idle and about +3 points in background swipes, no change in background commit-gap median, no memory increase, and theme-row differences inside the recorder-off run-to-run spread. Judged small enough to interpret counters, with that caveat. See `docs/evidence/theme-picker/board-session-2026-10-12/README.md` (section "Task 13.2 / 13.4: observer cost first"). - [x] 13.3 Gate all new speculative dwell/neighbor request admission while either carousel is held, coasting or settling. Preserve the bounded queue while paused, honor the latest settled center before neighbors, and resume only when both rows are at rest. Do not cancel or mislabel in-flight work: retain its request identity/completion accounting, and prove completion during motion cannot admit a successor. Foreground preview/activation and its prepared-slot protection must still work. Add regressions for each motion state on either row, neighbor fallback with no centered candidate, direction changes, in-flight completion while paused, bounded queue, resumption after settling, and foreground-request priority. Planned proof: `cargo test --offline --manifest-path nix/rust-shell-client/Cargo.toml --lib prepare_ahead` and `cargo test --offline --manifest-path nix/rust-shell-client/Cargo.toml --bin k230-shell-rust speculative_theme_motion`. **Completed 2026-10-10:** the motion gate already landed in `f4bf213e` after the historical audit below. Closed its remaining center-priority gap: queued neighbors now wait for the latest settled center's full dwell. Both named commands pass (7 preparation cases and a six-combination row-motion regression), along with 28 theme-UI and 22 route checks; `nix build .#handheld-shell-rust` passes under the sole build lock. A test-only old-source control fails at the prematurely admitted neighbor. Commands, hashes, source/artifact identities, host output, cross-build and verified extended retention are committed in `docs/evidence/theme-picker/admission-closeout-2026-10-10/README.md`. Host/cross-build only; tasks 13.1/13.2/13.4 and physical acceptance remain open. - [x] 13.4 On the reserved board, compare the repaired baseline and the admission-gated candidate with the 13.1 workload in at least three matched pairs, alternating order. Record exact packages and process/cache history; restore matching rows and selected state before every pair. Show no new speculative admissions during either row's motion, separately report work already in flight, and compare per-thread CPU, live rebuild/prepare/ thumbnail costs, warm commit-gap distributions, RSS growth and idle work. Capture native media separately, verify both rows visibly respond and the durable generation stays unchanged, and retain injected versus physical finger provenance. The planned operator interface introduced by 13.1 is `python3 /run/theme-picker-profile.py capture --plan /run/picker-plan.json --output /run/picker-profile.json`; the private plan supplies the verified device, runtime identities and row preconditions, and each run uses a unique output. Commit sanitized results, exact invocation and observer-cost limits. Run the 13.2 instrumentation off/on overhead pair (`overhead OFF ON`, same build, same workload, no native screencopy) first; interpret detailed counters only if it is small. If warm swipes or memory regress, or repaint still dominates, document the next measured correction and keep 11.3 open; a host regression or scheduling fix alone does not close it. **Performed 2026-10-12 (injected input, journal and Rust-trace timing, native grim stills and sampled frames; no finger, no camera):** overhead pair first, then three matched alternating-order pairs of `8cik` against the installed candidate in both recorder-off and trace mode (gates passed in all six pairs), with identical runtime references, a fixed catppuccin generation unchanged in every sample, 22 themes and 4 backgrounds, and both rows responding in native stills. Result: the candidate improves cadence and CPU (warm theme commit-gap median 137.5 to 114 ms, background 87 to 39 ms, render per frame 129 to 99 ms and 62 to 3 ms, full scene rebuilds down 69 to 100%) but **regresses memory** (+6.0 MiB at the end of warm idle in all three recorder-off pairs), warm-swipe p95 does not improve (173 to 160 ms), the theme row still repaints at about 93 ms per frame, and 23 to 33 full scene rebuilds still occur while `moving`. No speculative admission starts during motion in the candidate (0 of 36 in `held`/`moving`); admitted rest-time work is still in flight at the next touch. Next measured corrections (named, not done): row-local damage for thumbnail completion instead of a full scene rebuild; cut the theme-row repaint cost; attribute the RSS growth (**UNVERIFIED** cause) before changing the neighbour queue. **11.3 stays open.** Evidence, commands, runtime identities, observer cost and limits: `docs/evidence/theme-picker/board-session-2026-10-12/README.md`. Closeout audit (2026-09-28): all of 13.1-13.4 are genuinely unimplemented, not merely unchecked. `tools/theme-picker-profile.py` and `tests/test_theme_picker_profile.py` (13.1) do not exist anywhere in the tree; `cargo test --lib prepare_ahead` and `--bin k230-shell-rust speculative_theme_motion` (13.3) name targets that do not exist (`grep -rn speculative_theme_motion nix/rust-shell-client/` finds nothing). This is `af492080`'s own plan (task groups 13-16 of this file), landed as a plan commit only; 16 was implemented and closed (see below), but 13 was not started. This is real remaining scope for this change, not a board gate on already-written code -- keep the whole group open. ## 14. Make picker movement track the finger - [x] 14.1 Replace collapsed-slot input scaling with the inverse rendered card-center trajectory for both geometries. Test actual pixel displacement from integer/fractional positions, both directions, reversal, held ticks, clamping, release continuity and hold-then-release velocity expiry. Proof: `cargo test --offline --manifest-path nix/rust-shell-client/Cargo.toml --lib theme_carousel`. - [x] 14.2 Coalesce Themes-page touch-motion/frame-callback redraws until queued input has been dispatched. Keep latest position, buffer/frame readiness, taps, activation and other routes correct. Build the Rust shell and verify a burst of queued input does not cause one full render per stale motion sample; record the exact build and injected-board commands. - [x] 14.3 Trial the matching Rust build on the reserved board with automatic restoration, recording baseline/candidate identities, held-drag movement, reversal, stop/hold/release and both rows. Record input-to-commit timing separately from geometry and preserve saved generation. Capture native evidence separately from timing, then install the qualified build. - [x] 14.4 Obtain operator real-finger acceptance that both rows follow the finger without exaggerated travel or a backlog after it stops. Retain this gate if only host or injected evidence exists. This does not by itself complete the matched performance and memory comparison in task 11.3/13. **Operator real-finger report, 2026-10-12** on installed system `cwfx0amr…` (Rust `k2z9z61k…`): "it's really janky moving between theme cards and it shouldn't be". Not accepted. This matches the board session's measured theme-row repaint (about 93 ms per frame) and the full scene rebuilds during motion (`docs/evidence/theme-picker/board-session-2026-10-12/README.md`). **Sprite repaint, 2026-10-12** (`4d17b3c5`, Rust `b0yyyda8…`): with injected theme-row swipes, the moving-phase row repaint fell from about 80 ms CPU / 100 ms wall to 30 ms / 43 ms. Frames during the same swipes rose from about 51 to 91, and no full rebuilds occurred mid-swipe (`docs/evidence/theme-picker/row-sprites-2026-10-12/README.md`). After using it on the panel, the operator wrote "it's good to go land it". That accepts the feel for landing. **Accepted 2026-10-12** on installed system `rav09qp6…` (Rust `b0yyyda8…`): asked whether both rows follow the finger without exaggerated travel or a backlog after it stops, the operator replied "14.4 they do, is good". This acceptance does not cover task 18.4's curve properties or the 11.3/13 comparison. Task 14.1 host proof: the exact `--lib theme_carousel` command passed 13 geometry/physics tests, including rendered-center tracking for both rows, integer/fractional starts, reversals, held ticks, stale release velocity and screen-space fling continuity. `--bin k230-shell-rust` passed 19 route tests. Task 14.2: the matching full system built, and the guarded physical-board trial processed all 20 injected motion samples per phase with 2–4 commits during contact/hold. See `docs/evidence/theme-picker/finger-tracking/README.md` for commands, identities, geometry captures and timing limits. Task 14.3's qualified candidate was installed with verified backup/profile/boot hashes, then normally rebooted: all four shell services active, no failed units, and current/booted/profile identities all match `7hhr1fp…`. Task 14.4's real-finger acceptance remains open. ## 15. Reuse the profiler across the runtime - [x] 15.1 Add bounded, default-off shell/helper spans with shared trace and parent IDs; propagate context through the existing helper socket without changing ordinary requests. Export fixed-name, secret-free events into a Perfetto-compatible timeline. Test nesting, cross-process correlation, malformed records, overflow and clock mismatch with `python3 -m unittest discover -s tests -p 'test_runtime_trace*.py'` and `cargo test --offline --manifest-path nix/rust-shell-client/Cargo.toml --lib theme_picker`. - [x] 15.2 MOVED, NOT PERFORMED HERE: Extend verified coverage through compositor render/commit/present and scheduling; probe perf/kernel support and capture a bounded CPU stack profile with matching symbols. Produce a CPU flamegraph separately from the span timeline, or document the precise unsupported sampling/unwinding gate. Do not label elapsed spans CPU samples. Moved 2026-10-12 on the operator-authorized scope split: carried verbatim into the successor change `the-theme-swap-is-profiled-on-the-board` (task 2.1). Not performed here; no result is claimed. - [x] 15.3 MOVED, NOT PERFORMED HERE: On the reserved board, capture matching picker runs with tracing disabled/enabled and sampling separately. Publish reviewed timeline, frame-stall report and supported flamegraphs with runtime identities, commands, coverage gaps and observer cost. Use the same tools for at least one other interaction; host tests alone leave this physical gate open. Moved 2026-10-12 on the operator-authorized scope split: carried verbatim into the successor change `the-theme-swap-is-profiled-on-the-board` (task 2.2). Not performed here; no result is claimed. Task 15.1 proof: the two named host commands pass (9 Python and 3 Rust tests), plus 19 binary route tests and 54 helper/catalog/transaction tests. Both normal/coherent system closures cross-built. The first physical capture joined four helper requests and 36 presented frames without trace loss; see `docs/evidence/theme-picker/runtime-trace/README.md`. This leaves 15.2/15.3 and the broader task 13 profiling/overhead gates open. Task 15.2 partial physical proof: `nix build .#runtime-perf --cores 8` passed, and the physical board recorded 697 CPU-clock samples with matching PID/TID and unchanged generation. Reviewed CPU stack and flat hotspot SVGs are in `docs/evidence/theme-picker/cpu-profile/README.md`. Unknown callers appear in 648 samples; compositor stage spans, scheduler correlation and complete stack qualification remain open. This does not close 15.2/15.3. ## 16. Apply the selected background and show its result - [x] 16.1 Prepare the selected background before activation: the generation includes the background choice. Preserve the previous selection until commit succeeds; report progress, success and failure by the background row. Update the current theme identity only after acknowledged activation. Verify with `cargo test --offline --manifest-path nix/rust-shell-client/Cargo.toml --lib theme_ui` and `python3 -m unittest tests.test_theme_catalog`. Correct the QEMU fixture so different background choices have different generations. - [x] 16.2 Build the coherent shell system, then on the reserved board tap a different still background through the picker. Capture picker feedback and Home before/after; verify the selected generation and reopened picker agree. Record injected input separately from real-finger acceptance. - [x] 16.3 Land and deploy the qualified fix, publish reviewed evidence and check CI/site publication. Keep broader profiling and gesture gates open. Task 16.1/16.2 proof: 24 Rust and 21 Python checks passed; the corrected paired Sway/Rust QEMU fixture passed. The physical board applied both still backgrounds through injected picker taps; native Home images and the reopened picker agree. A shell/helper restart retained the selection. `docs/evidence/theme-picker/background-selection/README.md` records reviewed captures, generation identities and the successful persistent installation. Real-finger acceptance and a normal reboot of this image are not claimed. Task 16.3: source, physical evidence and image installation are on master. Pages run `36298384207` passed build and deployment at `d937b8cd`; the published evidence page and work card were checked over HTTP, including all five newly discovered screenshots. URL: https://knewter.github.io/tdisplay-k230/evidence/docs-evidence-theme-picker-background-selection-readme-md/ The archive override and missing screenshot inventory entries that blocked the earlier runs were corrected; wider tasks 11/13/14/15 remain open. ## 17. Reduce measured picker scene cost - [x] 17.1 Commit the resolved-two-row baseline, reviewed bounded traces and sampled CPU hotspot evidence with commands, source/runtime identities and sampling/unwinding limits; do not call commit intervals presentation FPS. - [x] 17.2 Gate speculative preparation on both rows being at rest and repaint position/pressed-only changes within the affected row. Preserve full invalidation for all semantic updates and thumbnail completion. Prove with `cargo test --offline --manifest-path nix/rust-shell-client/Cargo.toml --lib picker_row` (full-render pixel comparisons and fallback coverage), `--lib prepare_ahead`, and `--bin k230-shell-rust speculative_theme_motion`; build the Rust target. - [x] 17.3 Run matched baseline/candidate injected swipes for both rows on the reserved board with independent restoration; preserve generation and identities, report CPU/render/presentation separately and capture pixels outside timing. Install only the qualified candidate, land source/evidence, push and check actual site deployment. Retain task 14.4 real-finger and wider 13/15 gates. **Board comparison performed 2026-10-12** (installed candidate `ybpn`, source `faae7298`, against repaired baseline `8cik`): matched injected swipes on both rows, three alternating-order pairs in recorder-off and trace mode, independent restoration timer on every run, generation and process identities unchanged, CPU (recorder-off), render and presentation gaps (trace) reported separately, native stills outside every timing pass. Row repaint cut full scene rebuilds by 69 to 100% and background-row frames from about 87 to 39 ms median; theme-row repaint is still about 93 ms per frame. Evidence: `docs/evidence/theme-picker/board-session-2026-10-12/README.md`. **Not done by this commit (coordinator):** push, and the check of the actual site deployment named above. 14.4 and the wider 13/15 gates remain open. Task 17.1 baseline: `docs/evidence/theme-picker/row-repaint/README.md` records resolved two-row physical traces and the separate 1,490-sample CPU capture. Caller and observer limits remain open; the user still reports jank. ## 18. Tune touch momentum and settling against explicit references - [x] 18.1 Compare current pinned Omarchy picker source and Apple Motion HIG; record what they actually prescribe, local time-clamp/velocity discontinuities and the user's partial improvement report. Proof: source paths and primary reference URLs in design.md plus `openspec validate the-shell-swaps-themes-without-a-python-stall --strict`. - [x] 18.2 Implement full-elapsed-time release motion and velocity-continuous, interruptible centering. Compare trajectories across 8/16/33/100/200 ms ticks, slow/flick releases, stale velocity, reversals and end bounds for both rows with `cargo test --offline --manifest-path nix/rust-shell-client/Cargo.toml --lib theme_carousel`. **Completed 2026-10-12 (host only):** release motion now integrates the full elapsed time in closed form (exponential coast, k = -ln(0.88)/16 ms). Hand-over times inside a tick are exact for both the coast threshold and the catalogue bounds. The coast passes its exact velocity to a critically damped (omega = 22/s) settle, and a new touch catches the drawn position. The named command passes 21 tests. They cover 8/16/33/100/200 ms cadences against a 1 ms reference, seven slow/flick/bound releases, a stale hold, a reversal, a long hold followed by release, and interruption. Geometry is shared by both rows. The board trial (18.3) and finger acceptance (18.4) remain open. - [x] 18.3 Build and run a guarded board trial only after the row-render cost comparison, retaining a matching baseline. Publish position/velocity plots and injected tests; install only a qualified motion candidate. **Run 2026-10-12, task stays open.** Guarded trial of motion baseline `si9vd` against the installed candidate: 80 injected gestures (flick, slow, hold-release, reversal, interrupt; both rows; two repetitions in each of two alternating runs per build and row), generation and identities unchanged throughout, native stills after every settle. **The shell emits no per-frame carousel position or velocity, so no position or velocity plot is published** (a replay through the host model would be a model, not a board result). Published instead: injected finger path against frame-commit timelines, release-to-last-commit times, and the settled item per gesture (`docs/evidence/theme-picker/board-session-2026-10-12/README.md`, section "Task 18.3"). Closing needs a bounded per-frame position/velocity record in the Rust trace (not added in this session) and the matching curves. **Curves published 2026-10-12 (b)** (`docs/evidence/theme-picker/board-session-2026-10-12b/README.md`, "Task 18.3"): the shell now records one `carousel` trace event per committed frame (position, velocity and motion phase of both rows). Four trace-mode runs of the same five injected programs, two repetitions each, on candidate `3m65ag9z`, with generation and identities unchanged and no events dropped. Board-committed position and velocity curves for all 40 gestures are in `18.3/position-curves.png` and `18.3/velocity-curves.png`: monotonic approach and no overshoot in every gesture, and repeatable final items. The measured candidate was installed through the guarded coherent installer (system `fm8gk6xi…`); baselines `si9vd` and `8cik` stay pinned on the board. - [x] 18.4 Obtain user finger feedback on direct tracking, flick distance, final settling and interruption in both rows. A report of improvement alone is not acceptance of the curves. Keep this open if only injected tests exist. **Operator report 2026-10-12** on installed system `fm8gk6xi…` (Rust `3m65ag9z…`), after being asked about tracking, flick distance, settling and interruption on both rows: "yeah it's fast go". This is a report of improvement, not an answer on the four properties, so the task stays open. **Operator finger acceptance 2026-10-12** on installed system `298a2bf6…` (Rust `hl4bca4g…`), after being asked for tracking, flick distance (about 8 items), settling and interruption on both rows: "it's great now i tried it all it's good". Real-glass operator report; no camera recording or per-property measurement was taken for it. Task 17.2 source/build and the first task 17.3 injected pair: `docs/evidence/theme-picker/row-repaint/pair/README.md`. Task 17.3 remains open for combined-source installation and deployment; real-finger acceptance is separate. **2026-10-01 persistent-install reconciliation:** the combined coherent system `/nix/store/p1a1hz9n8s4g8qyr55ffl3dzbnjgqwr8-nixos-system-nixos-26.11.20260919.20b1ddd` was selected persistently and survived an ordinary reboot; the running Rust client is `/nix/store/3hy6h165ii649z6vjzjd36jwg16d37rc-k230-shell-rust-riscv64-unknown-linux-gnu-0.1.0/bin/k230-shell-rust`. The saved appearance generation and report hash match before and after boot. See `docs/evidence/boot-verification/coherent-ordinary-boot/README.md` and `postboot.json`. This verifies installation, ordinary boot and remembered appearance for the existing home. It does not complete 17.3: the existing matched pair measured candidate source `42e22d4353e35d9c4f6367ed5b159c80b489ec5d` and binary `/nix/store/2mprr7lqh4hznwz9z1zmv7vamf5nbnna-k230-shell-rust-riscv64-unknown-linux-gnu-0.1.0`, whereas the installed combined source is `5bb67db128210f830dab4de0d20a4b0eca13c578`. Between those identities `main.rs` and `render.rs` changed, including picker carousel painting, so the older pair cannot establish the current binary's matched swipe cost. A matched baseline/current-candidate pair is still needed; do not infer it from the normal reboot or unchanged appearance. The evidence inventory and source comparison are recorded in `docs/evidence/theme-picker/closeout-2026-10-01/README.md`. Tasks 14.4 (real finger), 10.7 (picker panel/tap timing), and wider 13/15 gates remain separate. ## 19. Load backgrounds on a board with no durable theme choice The 2026-10-12 board session found the Backgrounds row stuck on "Loading backgrounds" whenever `k230-theme list` reports `active: null`, the normal state of a board that has never applied a theme (`docs/evidence/theme-picker/board-session-2026-10-12/README.md`). The chooser only requested detail for a reported active theme, so no request was ever sent. - [x] 19.1 With no durable choice, centre the chooser on the packaged default theme (the built-in entry named by `K230_THEME_DEFAULT_GENERATION`'s `report.json` source; the first slice when it is unknown) and load its backgrounds without marking it "Current theme". Tapping it applies it durably. Proof: `cargo test --offline --manifest-path nix/rust-shell-client/Cargo.toml --lib theme_ui`. **Done 2026-10-12 (host only):** 33 tests pass, including five new regressions: default centred and its backgrounds loaded, unknown default falls back to the first slice, a user theme sharing the name is not used, a tap applies durably, and the report parser. The full suite passes except the existing `drawer_568x1232_matches_the_committed_evidence_exactly`. - [x] 19.2 On the board with no durable pointer, open Settings → Themes and capture the picker natively after it settles. The Backgrounds row must show the default theme's backgrounds, and `k230-theme list` must still report `active: null`. **Done 2026-10-12 (injected tap, native capture):** installed system `cwfx0amr…` (Rust `k2z9z61k…`), ordinary boot `a563b9fb`. The picker centred on built-in `catppuccin`, and Backgrounds showed Totoro instead of "Loading backgrounds". `k230-theme list` still reported `active: null`. Evidence: `docs/evidence/theme-picker/default-backgrounds-2026-10-12/README.md`. No finger observation.