# pixtuoid/tui — renderer agent guide

The **terminal render pipeline**: ratatui `App` + `TuiRenderer` (the `Renderer`
trait impl). Owns cross-frame state, the half-block pixel pass, widgets, themes,
the per-agent motion-timing authority, and A\* pathfinding. This is where the
headless `SceneState` becomes pixels. Parent guides:
binary [`../../CLAUDE.md`](../../CLAUDE.md); workspace
[`../../../../CLAUDE.md`](../../../../CLAUDE.md); headless lib
[`../../../pixtuoid-core/CLAUDE.md`](../../../pixtuoid-core/CLAUDE.md).

## Layout

```
tui/
├── anim.rs         centralized easing curves + eased_progress(start, duration_ms, easing, now) free function —
│                   used by floor slide, A* walk path ease, and version popup entrance/dismissal animations
├── renderer.rs     draw_scene orchestrator (DrawCtx struct), half-block flush (terminal lifecycle
│                   — raw mode + alternate screen — lives with the event loop in tui/mod.rs, #103)
├── widgets/        ratatui widget paint fns, split into sub-modules:
│                   mod.rs (TickerQueue, shared helpers), hud.rs (footer, wall display,
│                   elevator indicator, theme picker), tooltip.rs (hover, cat, coffee,
│                   furniture, labels, chitchat bubbles), help.rs (paint_help_overlay),
│                   dashboard.rs (paint_dashboard — the agent-dashboard popup)
├── hit_test.rs     mouse hit-test: agent hover, coffee machine click, furniture tooltips, pet
├── tui_renderer/   Renderer trait impl, split production vs tests:
│                   mod.rs (TuiRenderer: cross-frame state — Vec<RgbBuffer> + Vec<FloorCtx>; each FloorCtx owns
│                   its own FrameCache/Router/PoseHistory/OccupancyOverlay + .motion + .door_anim_max_ms; plus
│                   TickerQueue, Theme, cached Layout;
│                   #[cfg(test)] frame_buffer/floor_motion/floor_history/floor_buf/inject_coffee test seams),
│                   harness.rs (#[cfg(test)] mod: ~65 headless integration tests driving the real
│                   render()/navigate_floor() path via ratatui TestBackend — output-first: buf() pixels + frame_buffer cells;
│                   white-box seams only where an invariant isn't output-observable.
│                   NOT coverable headlessly, excluded in codecov.yml — incl. two files
│                   outside this tree: tui/mod.rs (crossterm event loop + real TTY),
│                   runtime/driver.rs (tokio block_on + ctrl_c + socket bind), main.rs)
├── theme/          color theme system — one file per theme, Theme struct in mod.rs
│                   mod.rs (struct defs + ALL_THEMES registry), normal.rs, cyberpunk.rs,
│                   dracula.rs, tokyo_night.rs, catppuccin.rs, gruvbox.rs
├── motion/         per-agent walk-timing state, split production vs tests:
│                   mod.rs (MotionState: entry/exit/snap_back/wander_*/walk_path fields — exit and
│                   snap_back are WalkLeg{started_at,profile,from} structs (named fields, was a
│                   3-tuple carrying the from-Point); entry stays a 2-tuple (SystemTime, WalkProfile);
│                   walk_path = frozen per-leg A* polyline via
│                   WalkPathSnapshot; WanderPhase enum Seated/WalkingOut/AtWaypoint/WalkingBack;
│                   octile_path_len; advance_wander drives the elastic wander timeline, idempotent
│                   per now via last_advanced_at; owned as HashMap<AgentId, MotionState> on FloorCtx.motion),
│                   tests.rs (#[cfg(test)] mod tests)
├── pose/           routed pose + motion-timing authority, split production vs tests:
│                   mod.rs (PoseHistory, derive_with_routing, snap-back; snapshots A* path length once at
│                   walk-start → freezes WalkProfile → drives t_x1000 per-frame via physics::walk_progress;
│                   the leg's A* polyline *shape* is also frozen once per leg via walk_path, re-snapshotted
│                   only when (from,to) changes and only for cornered routes >2 points, so per-frame overlay
│                   churn can't reroute an in-flight walker (no flash); octile_distance promoted to
│                   pub(in crate::tui); re-exports core::pose),
│                   tests.rs (#[cfg(test)] mod tests: unit + frame-by-frame continuity guards)
├── pathfind.rs     Router trait + AStarRouter with selective cache invalidation
├── frame_cache.rs  FrameCache — per-agent recolored-sprite cache keyed (agent_id, anim, frame_idx, flip_x);
│                   owned per-FloorCtx, flushed on theme change (set_theme) so recolors update immediately
├── embedded_pack.rs  include_str! the default character pack at compile time (from sprites/default/) →
│                   sprite::format::load_pack_from_strings; --pack-dir merges OPTIONAL_FURNITURE over it
├── layout.rs       thin façade re-exporting core::layout::SceneLayout as tui::layout::Layout (renderer's geometry entry)
├── floor.rs        FloorCtx (per-floor render state), FloorTransition, LightingState, build_floor_scene
│                   (projects one floor's agents into a self-contained uniform scene; the desk_index
│                   remap stays typed — it re-wraps as a GlobalDeskIndex valid FOR THAT smaller scene,
│                   so single_floor_local identity reads stay honest; see its doc comment + core's
│                   GlobalDeskIndex/FloorLocalDeskIndex docs in state/mod.rs)
├── pet.rs          PetKind (Cat, Dog) + per-kind static data; Pet{kind,name} (a configured office pet) + Pet::defaulted; select_pet_for_floor(u64,&[Pet])->Option<&Pet>; PetState (heart-anim interaction)
├── chitchat.rs     venue-keyed group/solo speech bubbles (VenueKey::Room vs ::Waypoint)
├── dashboard/      agent-dashboard PURE model (no ratatui): mod.rs (DashboardUi / DashboardRow /
│                   RowState / DashboardFolds; build_dashboard_rows tree builder; move_selection /
│                   reanchor_selection / resolve_floor / clamp_scroll; AUTO_COLLAPSE_THRESHOLD) +
│                   tests.rs. The ratatui painter is widgets/dashboard.rs.
└── pixel_painter/  pure-pixel pass — split into focused child modules:
                    mod.rs (PixelCtx struct, orchestrator), background/ (weather, sunset, skyline,
                    time_of_day.rs, lighting.rs), ambient.rs (sun spot + dust motes + ceiling halos),
                    drawable.rs (y-sort Drawable enum + dispatch), effects.rs (glow/z's/steam/dust/bubble),
                    palette.rs (agent palette + recolor + tool_glow_tint), anchors.rs (breath, walk position,
                    character_anchor; the per-pose anchor fns take `sprite_w` — the pack's character width,
                    resolved ONCE per frame [8 bundled / 10 robot] — so a non-8-wide pack centers correctly),
                    furniture.rs (meeting table, area rug, side table, pantry table/chair),
                    glass.rs (frosted-glass room-divider walls: consts + stitch + paint_glass_wall_h/v),
                    seat.rs (SeatView orientation single-source + seat_sprite + settle_seat_view + paint_character_at),
                    tests.rs (sibling unit suite, extracted from mod.rs)
```

> Furniture drawables y-sort via `core::layout::z_sort_row` (the south base row, tied to the mask's
> `anchored_top_left` so the sprite and its blocked ground can't drift); `center_pin_south_offset` remains
> only as the offset primitive for shadow/halo placement. See `core::layout::placement`.

## Known sharp edges (don't be surprised by these)

- **`draw_scene` is called through `TuiRenderer`** (the `Renderer` trait impl), which owns the cross-frame state (RgbBuffer, FrameCache, Router, OccupancyOverlay, PoseHistory) and assembles a per-frame DrawCtx borrow (RgbBuffer, FrameCache, Router, OccupancyOverlay, PoseHistory, theme, mouse state). `draw_scene` returns `Result<Option<Layout>>` — the computed layout is cached on `TuiRenderer.cached_layout` so hit-test functions can use it without recomputing. During floor transitions, `cached_layout` is cleared to `None`.
- **`recolor_frame` substitutes by RGB equality.** Works because each recolor key maps to a unique RGB. The recolor key set is `pixtuoid_core::sprite::format::RECOLOR_KEYS` (`B/H/S/P`) — the SINGLE source of truth `recolor_frame` iterates AND `validate_recolor_palette` guards, so the substitution and the guard can't drift (add a 5th recolor key there, once). **The uniqueness invariant is now ENFORCED at pack load** (`validate_recolor_palette` in `sprite/format.rs::build_pack` bails on a collision) for the embedded AND `--pack-dir` custom packs — it is no longer just "documented, be careful." If you genuinely need two recolor keys to share a color, swap to a palette-key-indexed approach instead.
- **EXIT walks are time-compressed to fit the GC window; entry/wander/snap-back are not.** Walk duration is normally pure physics (`distance ÷ speed`), but an exiting slot races a removal deadline: the reducer GCs it after `EXIT_GRACE_WINDOW = 4500 ms`, so when the exit's physics duration exceeds the window `derive_with_routing` scales `elapsed` so `walk_progress` reaches 1000 by the window edge — without this the sprite would **vanish mid-corridor** when the deadline fires (a real regression, fixed with a test). Don't delete the exit compression as "redundant." **Snap-back is NO LONGER compressed** (it used to be, to fit a 900 ms window): it now runs by **pure physics** with a brisk SnapBack profile (`physics::V_CRUISE_SNAPBACK` faster cruise + `WALK_ACCEL_SNAPBACK` ≈ 3× accel — an "urgent rush back"), and `SNAP_BACK_MS = 900 ms` is now just the **ARM window** (only fire a snap-back for a recent flip), NOT a render cap — so a far snap-back renders to completion as a real ≈ 1.3 s walk instead of a hard-compressed 900 ms dash. Entry has no cap (nothing GCs an entering agent) and must stay uncompressed so far desks genuinely take longer.
- **A walk leg's A\* polyline shape is frozen once per leg, not re-routed per frame.** `route_walking_pose` snapshots the route into `MotionState.walk_path` keyed on `(from, to)` and reuses it until the endpoints change. This is NOT redundant with `AStarRouter`'s own cache: the router cache is *invalidated* by per-frame occupancy-overlay churn (another agent toggling a waypoint obstacle), and without the freeze a mid-leg re-route remaps the frozen-progress `t` onto a differently-shaped polyline → the sprite **jumps** (the "flash") and the frame pays a fresh A\* cost (the periodic stutter). Only cornered routes (>2 points) are frozen; straight 2-point walks re-route each frame (cheap, and self-healing if A\* transiently fell back to a straight `[from,to]`). The accepted trade-off: a frozen walker won't dodge an agent that steps into its path mid-leg (rare, cosmetic, legs are seconds). Don't "simplify" the `walk_path` check away as duplicate caching. The occupancy overlay is still built from the *stateless* `core::pose::derive` (for cache-signature stability) — that intentional divergence from the tui motion timeline is what made the freeze necessary.

## Where to look

- "Which side does an agent approach furniture from?" → `core::layout::approach_point` (`layout/approach.rs`) is **A\*'s goal**; `stand_point` is the obstacle render anchor (they're equal for obstacles). A waypoint's `pos` is the furniture's blocked CENTER. Both pick the side **nearest the agent's home desk**, but `approach_point` adds two filters the old `walk_target` lacked: (1) the side must be in `FurnitureDef.approach` (`ApproachSides`, rotated by the seat's `facing` — a North-facing couch excludes its south backrest); (2) the chosen cell must be **A\*-reachable** (`ReachSet.reaches`, a coarse-cell BFS mirroring the router's grid coarsening) so A\* never targets a walled-off cell. Pure geometry over `WalkableMask` + `ReachSet`, no live A\*. Called by `core::pose::idle_pose` (overlay walk dest) + `tui::motion::pick_wander_dest` (routed walk dest) with the same `origin = home desk` so they can't drift.
  - **Obstacle kinds** (pantry/vending/printer): `approach_point == stand_point` — the agent stands AT the approach point, render anchor == A\* goal.
  - **Seat kinds** (`occupies_pos`: Couch/MeetingSofa/MeetingStand): A\* routes to the approach point (a cell off an *allowed* side), then a post-A\* **settle** appends `seated_foot_cell(furniture, pos)` (the seat's render-anchor *inverse* — `back_couch` seats → `pos+5`, waypoint stands → `pos`) to the walk polyline, so the sprite glides onto the seat **pop-free** (the settle endpoint == the render anchor by construction). The seat cell stays *blocked* (the sprite sits ON the furniture); the approach/settle split is what replaced the old "A\* snaps adjacent + sprite pops to a fixed north anchor." Don't collapse the settle back into a single `pos` target. **NO approach-side fallback:** if no allowed+reachable side exists (a seat boxed in to only its backrest — only happens in the degenerate tiny 120×96 meeting room), `approach_point` returns the blocked `pos` as a **"no valid approach" sentinel** and the caller (`pick_wander_dest`/`idle_pose`) **skips the furniture this cycle** (ambles aimlessly) rather than routing into it — a back-approach (standing behind the backrest then sitting facing north) is physically impossible, so it's never chosen. Invariant pinned by `seat_approach_is_never_behind_the_backrest_on_real_layouts`.
  - **The home desk** is a seat whose chair (`desk_walk_anchor` == `seated_foot_cell(Desk)`) is *inside* its blocked footprint, so it can't be A\*'s goal directly — aiming the router at it makes `find_path` snap the blocked goal to the NEAREST walkable coarse cell, which for a south-facing chair is the SOUTH/corridor front, so the agent routes up THROUGH the desk front (the "walk through the table" / approach-from-south bug). **EVERY desk-touching leg — entry, wander-out, wander-back, and the exit DEPARTURE — therefore shares ONE `pose::desk_leg_endpoint(desk, layout)` helper** that returns `(routing_endpoint, chair_settle)`: the routing endpoint is `desk_approach_cell` (= `approach_point(Furniture::Desk, …, Facing::South)` — a reachable N/E/W cell, the south front excluded by `DESK_APPROACH`), and `chair_settle = Some(chair)` is the cell to SETTLE onto. So A\* always gets a real reachable goal and a post-A\* settle glides the sprite onto the chair; `desk_walk_anchor` stays the seat render anchor + the settle target, NEVER the raw router goal. The arrival glide renders front-facing at the desk's seated z-key (`desk.y+4`, below the desk furniture's `desk.y+8`) via `SeatView::Front`, so it sorts behind the desk. A wander leg uses `Settle::Both` (rise off one seat AND glide onto another — e.g. wander-back rises off the waypoint seat then glides onto the chair); entry/exit use single-ended `Settle::End`/`Settle::Start`; the motion-side wander/exit profiles add the chair-glide length so the duration matches (no pop). **Snap-back joins the unified path too** (the last exception erased): the urgent Idle→Active return routes via the approach cell + settle like the rest, run by **pure physics** with a brisk SnapBack profile — no 900 ms time-compression. Two things make that safe: (1) its arm gate measures distance to the **chair** (`desk_walk_anchor`), NOT the desk origin — the chair is +(6,4) from the origin, so a desk-origin gate re-fired forever once the agent settled ON the chair (10 px ≥ MIN); (2) it arms ONCE per state transition and renders from the FROZEN origin, so a K-call within a frame can't flip Walking↔Seated. The ONLY non-caller is a mid-wander EXIT, which starts from its live wander position rather than the chair. Degenerate fallback: if `desk_approach_cell` returns `None` (every allowed side walled off), the leg reverts to the direct chair target with no settle. So `approach_point(Furniture::Desk)` is called in production by every desk leg (entry/wander/exit/snap-back); seat render for the desk stays its own arms (4 work-states: typing/thinking/sleeping/`StandingAtDesk` at z=`desk.y+0`), NOT folded into `SeatView::seated_sprite` — only the arrival glide unifies. Pinned by `desk_entry_routes_around_the_desk_then_settles_onto_the_chair` + `wander_legs_approach_the_desk_via_an_allowed_side_not_through_the_front`.
  - **Debug (debug builds only):** toggle `w` (`pixel_painter/debug_overlay.rs`) to see the mask (red), approach points (green) vs seat cells (magenta), and live A\* routes (cyan). The `w` dispatch arm and help entry are `#[cfg(debug_assertions)]`-gated — `w` does nothing in release builds. The snapshot example's `--debug-walkable` renders the same overlay + a BFS connectivity report.
- "How is the office rendered (pixel pass)?" → `tui::pixel_painter::render_to_rgb_buffer`. The pixel pass is split: `pixel_painter/background/` (floor/walls/windows/clock/corridor/lighting/shadow via `mod.rs` + `time_of_day.rs` + `lighting.rs`), `pixel_painter/drawable.rs` (y-sort `Drawable` enum + dispatch), `pixel_painter/effects.rs` (chair-behind/screen glow/sleep z/steam/dust/bubble), `pixel_painter/palette.rs` (agent palette + recolor + `tool_glow_tint` for per-tool monitor color), `pixel_painter/anchors.rs` (per-pose sprite anchors + breath + walking_position + character_anchor), `pixel_painter/furniture.rs` (procedural furniture paint helpers). The terminal-flush pass (`half-block + widgets + status footer`) is `tui::renderer::draw_scene`.
- "How do the room dividers render (frosted-glass partitions)?" → the `room_walls` loop in `render_to_rgb_buffer` (`pixel_painter/mod.rs`), painted AFTER `dim_floor_overlay` so the glass isn't dimmed. Each layout segment is a **frameless frosted-glass** strip — no hard outline: a cool gradient across the short axis (a bright specular edge → tinted body → soft slate edge, all three derived from `room_wall_trim_light` and alpha-composited via `glass_over` (`palette::blend`) so the floor glows through), plus a subtle brighter **seam** every `GLASS_SEAM_STRIDE` px for panel rhythm. The vertical's joint stitching is the pure `stitch_vertical_wall` helper. Thicknesses: **`WALL_THICK_H_PX=6` (E-W, shows its face) vs `WALL_THICK_V_PX=3` (N-S, edge-on)** — the 2:1 ratio sells the top-down fake-3D. `WALL_THICK_H_PX` is **defined as `pixtuoid_core::layout::WALL_THICK_H`** (the mask footprint) — one source of truth, so the visible face and the blocked ground can't drift; the vertical's 3 px visual is intentionally wider than its 1 px walkable footprint (top-down ground-projection rule, invariant #6). **Paint order / occlusion:** the VERTICAL wall paints in the background pass (`paint_glass_wall_v`); the HORIZONTAL wall is emitted as a `DrawableKind::RoomWallH` into the y-sorted drawable pass (`paint_glass_wall_h`), anchored at its **south (front) base** — so a character standing just north of it is composited *behind* the glass (occluded), same depth trick the `Door` drawable uses. To make that occlusion visible in a pure top-down projection, the horizontal glass rises `GLASS_CAP_PX=6` px NORTH of its footprint (a translucent "back cap" over the meeting-room floor — visual only, not in the mask; `GLASS_CAP_PX` is defined as `WALL_THICK_H_PX` so the cap grows with the wall-face thickness — was 3, which only grazed the single feet row), so a walker's feet/legs read behind the frosted pane. The loop also **stitches the vertical's joints** the terminal-agnostic layout can't know about: a segment starting at `top_margin` is raised to `top_wall_h` (connect to the window band), a segment sitting just below a horizontal wall is bridged up to it (the dual-meeting layout offsets the lower segment ~6 px to clear the cross wall), and a segment whose bottom meets a horizontal row extends down by the horizontal's thickness to fill the inside corner. Covered by `harness.rs::meeting_glass_partition_connects_at_window_and_corner` + `pixel_painter::tests::glass_wall_h_back_cap_composites_over_a_character_behind_it`.
- "How does the neon wall display work?" → `pixel_painter/background/lighting.rs::paint_neon_panel` paints the dark panel with pulsing cyan border in the pixel buffer; `widgets/hud.rs::paint_wall_display` overlays ratatui text (branding, state dots, scrolling ticker); `widgets/mod.rs::TickerQueue` manages the persistent scrolling message buffer.
- "How do pets work?" → `tui/pet.rs::PetKind` enum (Cat, Dog) with per-kind static data (sprite names, hitboxes, behavior). **`config::resolve_pets` resolves config into `Vec<Pet>` ONCE at startup** — each `Pet { kind, name }` carries its display name (custom from the `[[pets]]` stanza's `name` field, else `PetKind::default_name()`). One pet per floor is selected via `select_pet_for_floor(floor_seed, &[Pet]) -> Option<&Pet>`; the picked `&Pet` flows into `DrawCtx.floor_pet`, so the tooltip reads `floor_pet.name` directly — **no per-frame name lookup, no parallel kind→name map**. Config: `[[pets]]` stanzas (`kind` required string, `name` optional; absent section = all kinds with default names; `pets = []` = no pets; unknown `kind` = warn+skip, non-fatal). `pixel_painter/drawable.rs::pet_position` — 40s cycle, picks a destination from all spots (desks, pantry, sofas, couch, corridor), walks there (35%), sits/sleeps (65%). **Walk routing RESPECTS the walkable mask**: `find_path(&layout.walkable, &OccupancyOverlay::new(), corridor, prev, dest)` — A* on the STATIC mask with a throwaway EMPTY overlay, arc-length-sampled (`sample_polyline`) for uniform speed. The empty overlay is deliberate: the pet ignores live-agent occupancy so every frame of a leg calls A* on identical inputs → bit-identical polyline → no flash, no per-frame state (contrast agents, whose `walk_path` freeze exists only because they route on the DYNAMIC overlay). Raw spots are pre-snapped via `pathfind::snap_point_to_walkable` (a static-mask wrapper over the private `snap_to_walkable`) and written over the polyline endpoints — necessary because `find_path`'s `reconstruct` puts the RAW (often-blocked) `from`/`to` at the ends. Same snapped anchor for walk-end and rest → no pop at the leg boundary. No-path fallback: straight lerp between the snapped anchors (never panics, still better than the old raw-spot lerp). Cat sleeps with z's near idle agents; both pets sleep when all agents are idle. Sprites per kind: `*_walk` (8×6), `*_sit` (6×6), `*_sleep` (6×4). Click to pet → hearts animation via `PetState` on `TuiRenderer`. `hit_test_pet` / `paint_pet_tooltip` parameterized on `PetKind` (kind drives sprite/hitbox); the tooltip's NAME comes from `DrawCtx.floor_pet`. A floor's name is shared by every floor showing that kind (the model is per-KIND, not per-instance).
- "How does the coffee run work?" → `Pose::Walking.carrying_coffee` set in `idle_pose` walk-back from Pantry → `walking_coffee` sprite selected in pixel_painter → `coffee_holders: HashSet<AgentId>` on `TuiRenderer` tracks which agents have visited the pantry (inserted when the pixel pass sees `carrying_coffee: true`) → cup persists on desk until agent exits → exit walk overrides `carrying_coffee` from `coffee_holders` in the pixel painter → `coffee_fetched_at` timestamps drive 120s steam window. The desk cup itself is painted by `drawable.rs::paint_desk_coffee` (the sole survivor of the removed desk-personalization feature — the timed plant + photo frame were deleted).
- "How do atmosphere / ambient effects work?" → `tui/pixel_painter/ambient.rs` is the orchestrator, called between background and y-sorted drawables. Five effects: `paint_sun_spot` (East/West walls only — South is the window strip, see `sun_on_wall` in `background/time_of_day.rs`); `paint_dust_motes` (anchored to `window_spill_columns` returned by `background/mod.rs`); `paint_ceiling_halos` (gated on `Theme::kind == ThemeKind::Dark`, color from `palette::tool_glow_tint`). Weather floor tint is in `background::weather_floor_tint`, applied in `paint_floor_and_walls` at 0.15 blend.
- "How does the theme system work?" → `tui/theme/mod.rs` defines the `Theme` struct (~114 color roles in 9 groups, incl. `ApplianceColors` — vending/printer/coat-rack colors; these were hardcoded RGB literals in `drawable.rs` so corridor appliances rendered with the NORMAL palette on every theme until each theme supplied its own harmonized set, guarded by `appliance_palette_is_legible_for_every_theme` — and `SourceColors`, the per-CLI agent-dashboard badge hues (`claude_code`/`codex`/`reasonix`/`antigravity`/`codewhale`/`opencode`), guarded by `source_badges_legible_for_every_theme` — which also enforces a `MIN_SOURCE_HUE_DIST=60` Manhattan floor between every pair so a new source's hue can't perceptually collide). Each theme is a `pub static Theme` in its own file (e.g. `theme/cyberpunk.rs`). `ALL_THEMES` is the registry slice. `--theme` CLI flag resolves via `theme_by_name()`. The `&'static Theme` threads through `TuiRenderer` → `draw_scene` → `render_to_rgb_buffer` → all paint functions. Press `[t]` in the TUI for a live preview picker (j/k or ↑↓ to navigate); each row shows a 2-cell swatch (`hud.rs::theme_swatch` → `neon_brand` + `carpet_base`) drawn from that row's own palette. `set_theme()` flushes the `FrameCache` so character recolors update immediately. 6 themes: normal, cyberpunk, dracula, tokyo-night, catppuccin, gruvbox.
- "How are popups framed?" → **every popup is borderless** (no outline): `tui/widgets/panel.rs::borderless_panel` renders `Clear` + a solid `tooltip_bg` Block (NO borders), insets the content by a uniform `PANEL_PAD_X`/`PANEL_PAD_Y` (the breathing room that replaces the border — re-exported from `widgets`), draws an optional bold brand-colored title row at the top of the padded region, and returns the inner content `Rect`. Help / theme-picker / version-popup / dashboard / connection all route through it (each `centered_in` adds `2×PANEL_PAD_*` to its content size); tooltips use the borderless padded `tooltip.rs::framed_tooltip` (a `Padding::uniform(1)` Block, no border) for the same look. The bg fill + `Clear` keep text legible over the office; the title moved from the old border into the body. **The version popup's URL click-rect (`version_popup_url_rect`) derives its offsets from the SAME `PANEL_PAD_*` consts the painter insets by** (`url_x = popup_x + PANEL_PAD_X + URL_PREFIX.len()`, edges clipped at `±PANEL_PAD_*`) — keep them in lockstep (the phantom-browser-launch regression class).
- "How does the `?` help overlay work?" → `tui/widgets/help.rs::paint_help_overlay` paints a centered borderless panel (`borderless_panel`, title "? Keyboard") listing every shortcut (incl. `s` sources). Open state on `TuiRenderer::help_open` (toggled by `?`; Enter/Esc/`?` dismiss — the close guard runs BEFORE the version-popup/theme-picker key handlers in `tui/mod.rs`). Threaded through `DrawCtx.help_open`, painted last in `draw_scene`.
- "How does the agent dashboard work?" → `Tab` toggles a modal popup: the PURE model `tui::dashboard` + the painter `widgets::dashboard::paint_dashboard`. The model turns `SceneState` into a foldable parent→subagent **tree** (`build_dashboard_rows`: roots by `desk_index`, subagents nested, orphans-as-roots, recursive so deeper nesting is never silently dropped); `DashboardFolds` auto-collapses any root over `AUTO_COLLAPSE_THRESHOLD` (5) so a ~20-subagent CC workflow doesn't flood the board (manual `←/→` fold overrides and STICKS via `user_toggled`; `z` fold-all). Selection is tracked by `AgentId` (`move_selection`/`reanchor_selection` — stable across the 30fps rebuild, re-anchors when the selected agent exits); `Enter` resolves the selected row's floor (`resolve_floor`) and calls `navigate_floor`. State lives in `run_tui`'s `DashboardUi` (persists across open/close for the session); each frame, while open, it builds rows → reanchors → `clamp_scroll` (shared `DASHBOARD_VIEWPORT_ROWS`) → `set_dashboard_frame` to the renderer, which paints the centered/cleared **borderless** popup (via `borderless_panel`) in BOTH the normal and floor-transition draw paths via `DrawCtx.dashboard_*` (the row-slice borrow is disjoint from the `floor_ctxs`/`floor_bufs` borrows; the transition path clones for its brief frames). Dispatch precedence: help > version > **dashboard** > theme-picker > normal (mutually exclusive with the picker; `Tab` only opens from the normal scene, and the modal passes the quit chord through). Model unit tests live in `dashboard/tests.rs`; painter/render coverage in `tui_renderer/harness.rs` (`dashboard_*`). **Per-row presentation** (`dashboard_line`): a leading per-CLI `[xx]` badge colored from `Theme::source` (`SourceColors`), resolved via `descriptor_for(row.source).label_prefix` → a `match` arm (unknown source → `[??]`/`label_idle` fallback); the agent **name is tinted by `RowState`** (active/waiting/idle); and the badge span is intentionally **NOT** `REVERSED` on the selected row (a low-luminance hue inverted vanishes against the highlight bg — only the name/floor/state spans reverse). A `⋮ N more ▾` cue marks below-window overflow, reserving its own line so it never displaces the selected row (and folding back to full height when the selection scrolls to the very end — no blank line). **The SELECTED row's overflowing name + activity cells auto-scroll** (ping-pong): `marquee_or_truncate` swaps the static `…`-`truncate` for `marquee_window` (a stateless `now`-windowed slice, same wallclock trick as `TickerQueue::visible` — `widgets/mod.rs`, consts `MARQUEE_MS_PER_CHAR`/`MARQUEE_END_PAUSE_MS`); non-selected rows stay `…`-truncated, the scrolling window emits no `…`, and `[p]ause` freezes `now` (and thus the scroll). The painter takes `now` (threaded from `draw_scene` at all 4 call sites incl. the floor-transition path). Badge-color correctness is guarded by `source_badges_legible_for_every_theme` (`theme/mod.rs`) + `every_registry_source_has_a_non_fallback_badge_color` (`widgets/dashboard.rs`). For headless capture, the `snapshot --dashboard` flag renders a fixed representative scene (a `cc` parent + 2 subagents + a `cx` root + an `rx` root, `dashboard_scene`) with the popup open — driven by the `dashboard` job in `scripts/media.json` → `docs/images/dashboard.png`.
- "How does the footer color-code state?" → `widgets/hud.rs::status_segments` builds `(text, role)` pieces once; `build_status_spans` (production) tints active/waiting/idle counts by hue (`label_active`/`waiting`/`idle`); `build_status_summary` (`#[cfg(test)]`) concatenates the same pieces as the byte-identical text-contract oracle for the insta snapshots. Tier fallback (full→medium→min→quit-only) and the right-flush padding key off **display-column** width (`chars().count()`, matching the source-warning branch) — the footer carries single-column multi-byte glyphs (·, ×, ↑↓), so byte length over-counts and short-pads the row (was a real bug; the column measure right-flushes `[q]uit` to the exact edge). **A source-death warning (#157) preempts the tiers**: when `DrawCtx.source_warning` is set (from the runtime health channel via `TuiRenderer::set_source_warning`), the warning replaces the stats portion entirely (they'd be stale — the office is partially frozen), truncates-with-… instead of tiering away (it must survive every width), keeps the `[q]uit` suffix, and uses `SegRole::Warning` = the `label_waiting` attention color (no new theme key). Painted on both the normal and floor-transition paths. **The `source_warning` string is a TWO-TIER merge** (`doctor::footer_warning(source_death, drifted)` in `run_tui`): source-death (highest — a frozen office) short-circuits, else a passive **decode-drift nudge** (`⚠ decode drift: cc· cx· — run pixtuoid doctor`) when any registered source has accumulated `pixtuoid::drift` breadcrumbs. The drift list comes from `doctor::drifted_sources`, which `run_tui` throttle-scans from the warn-floor log (`RunConfig.log_path`, resolved by `main`) at most every ~15s — the ONE deliberate exception to "no scan-the-history" (it derives a passive diagnostic hint from the log artifact, NOT lifecycle state; a counting `tracing::Layer` was rejected to avoid stateful blast radius on the single global file subscriber). Drift is breadcrumb-driven ONLY — never the `verified_version` skew, which stays advisory in `doctor`. `None` log path (headless) = no drift surfacing. Breadcrumbs from a prior rotated session (in the `.old` log) are not reflected — both the footer and `doctor` read only the primary log.
- "How does the version popup render?" → `hud.rs::paint_version_popup` paints a centered borderless panel (`borderless_panel`, title "What's new in vX — Enter to close") that scales in/out via `popup_scale` (entrance EaseOutCubic/200ms, dismissal EaseInQuad/120ms). The clickable "More details" URL's screen rect is `hud.rs::version_popup_url_rect`, derived off the SAME `version_popup_envelope` geometry the painter uses — borderless, so the URL sits flush (`popup_x + URL_PREFIX.len()`, no border inset) and clips against the exclusive envelope edges; keep the two in lockstep (the phantom-browser-launch regression class). (The old `pulse_border_color` border-breathing glow was removed with the border.)
- "How does the Sources panel work?" → `s` toggles the **Sources** panel (renamed from `c`/"Connection" once it grew past bind/unbind into a per-source board — connection + live + health; the module + types keep the historical `connection`/`Connection*`/`tui::connection` names, only the UI title + keybind changed): the PURE model `tui::connection` + the painter `widgets::connection::paint_connection_panel`. It is the SOLE way to bind/unbind a source — there is **no `install-hooks`/`uninstall-hooks` CLI** (deleted; `pixtuoid` is the only command). ONE navigable list, one row per agent CLI = the **union** of install targets + registry sources keyed on the source id (`build_rows(&connected_set, log)` joins `source::registry::REGISTRY` to install targets via **`install::target::by_source`** — on `Target.core_source`, NOT `Target.name`, which differs for Claude: target `"claude"` vs source `"claude-code"`; joining on `name` made the flagship row a no-op row, the review CRITICAL pinned by `build_rows_makes_every_source_with_a_target_actionable` + `every_target_names_a_registered_source`; Antigravity has no target → connect/disconnect is a flag-only flip). Each row shows a colored `[xx]` badge (same `Theme::source` mapping as the dashboard, never `REVERSED`), a **Connection** facet (`ConnState`: `connected`/`disconnected`/`no CLI`) and a **Live** facet (`N agents · age` / `idle` / `⚠ transport died`), plus a socket line and a **state-aware detail line** (armed-confirm prompt > last action result > **health verdict** > per-state: Connected → `installed at: <path>`, Disconnected → `press t to connect` (no path — meaningless until bound), NoCli → `<name> not detected on this machine`). **The health verdict** (the #309 health-consolidation: `⚠ install broken: …` > `⚠ N decode drift — run pixtuoid doctor`) is `doctor::diagnose(src, log).summary()`, cached on `ConnectionRow.health` and computed in `build_rows` for CONNECTED rows ONLY (`connected.then(diagnose…)` — a disconnected row's stale soundness/drift isn't actionable, and `diagnose` itself gates the install check on `has_hooks`, so a just-disconnected source is never falsely "broken"); it PREEMPTS the benign per-state line. `doctor` is the health PROVIDER, `ConnState` the connection lifecycle it ANNOTATES (sub-state, not overlap). Version skew stays `doctor`-only (the probe is too costly for the panel-open). **Two facets, two lifecycles**: the CONNECTION facet is driven by the live connected-set (the persisted `[sources]` intent) + an FS presence read (`is_present` → `NoCli`), so `connection_ui.rows` is cached — rebuilt on open + after each toggle (`build_rows(&connected.snapshot(), log)`), NEVER per frame; the LIVE facet (`live_view`) is recomputed per frame from the scene snapshot (group `AgentSlot.source`, max `last_event_at`) + the captured `&[SourceDeath]` (the same health snapshot the footer reads — no retained renderer map). **One toggle, not separate i/u**: `t` on a Disconnected row CONNECTS immediately (additive, reversible — `tui::mod::connect_source`: persist the flag via `config::save_source_connected`, `connected.set(src, true)`, then `install::install_target` for target-bearing rows); `t` on a Connected row arms an inline `y/n` confirm → `tui::mod::disconnect_source` (persist false, `connected.set(src, false)`, `install::uninstall_target`), both via the load-bearing `ConfigLock` round; results render through `connection::format_{connect,disconnect}_result`. The live gate + graceful eviction live in `runtime/driver.rs::reducer_task` (see the binary guide): `connected.set` is the panel's mutation seam, read by the per-event gate (`event_source`) + the per-tick reconciler (`Reducer::reconcile_connected`, which evicts every slot whose source is the COMPLEMENT of the connected-set — so it also sweeps a blank-source gate-slipper), so a disconnect walks characters out live (no restart). Dispatch precedence: help > version > **connection** > dashboard > theme-picker > normal; the open tier splits armed (`y`/`n`/`Esc` only) vs unarmed (`j`/`k`/`t`/`s`/`Esc`); the quit chord always passes through. `s` opens/closes it; bare `c` is now UNBOUND (the panel moved off `c`); only `Ctrl+C` is the quit chord. The socket path + the `ConnectedSources` handle are threaded from `runtime/driver.rs` into `run_tui`. Painted in BOTH draw paths via `DrawCtx.connection_*`. **The SELECTED row's name + the (selection-driven) detail line auto-scroll** when they overflow — `marquee_or_truncate`/`marquee_window` (shared with the dashboard, `widgets/mod.rs`) ping-pong the focused text; the detail line is always the selected row's, so it scrolls whenever its `installed at: <path>` overruns the panel. Non-selected rows stay `…`-truncated. The painter takes `now` (same threading as the dashboard). Headless capture: `snapshot --connection` renders a fixed representative fixture; model tests in `connection/tests.rs`, painter tests in `widgets/connection.rs`, render + dispatch tests in `harness.rs`/`mod.rs`. Badge legibility reuses `source_badges_legible_for_every_theme` + the connection twin of `every_registry_source_has_a_non_fallback_badge_color`.
- "How does weather work?" → `pixel_painter/background/time_of_day.rs::weather_state` picks from 8 variants (Clear/Rain/Storm/Snow/Fog/Overcast/Windy/Smog) via splitmix64 hash of `wallclock / 600` (changes every 10 min). Effects paint on window glass after the skyline. `sunset_strength()` adds a time-based golden-hour tint at ~6am/6pm, scaled down by existing twilight intensity to avoid double-orange. Smog amplifies the sunset blaze (1.5×) for a sodium-lit golden hour. **`weather_light(weather) -> { intensity, beam_strength, night_sky }`** is the physically-grounded light model: `intensity` (0..1) is daytime diffuse sun (drives spill + `darkness = 1 − max(day_eff, night_glow)`); `beam_strength` (0..1, replaced the old `has_direct_beam` bool) scales the wall sun-spot + dust motes — full under Clear, faint through haze/thin-cloud/snow-glare (Snow/Smog/Fog), zero under thick overcast/rain/storm; `night_sky` (0..1) is night-side luminance (moon/stars/snow-glow/sodium haze) so a clear/snowy night reads brighter than a storm night (`night_glow = night_sky·(1−day)`) — without it every weather rendered an identical pitch-black night. `background::skyline_haze(weather)` murks the city behind the glass under fog/storm/rain/smog (fog is a near-total white-out). Storm lightning: `lightning_envelope` (two-pulse flicker, every `LIGHTNING_PERIOD_MS=15000` ≈15s, lasting `LIGHTNING_FLASH_MS=90`) drives BOTH the bright on-glass bolt (`paint_floor_to_ceiling_window`) and the room-wide ambient bounce (`paint_lightning_flash`, painted LAST in the pixel pass so the whole interior flares). City light twinkle is ~0.6–1.4s cycles at 75% lit. The falling-particle loops (Rain/Storm/Windy streaks + Snow flakes) on the glass are unified through ONE `paint_streaks(&StreakSpec, …)` + a `Particle::{Streak,Flake}` discriminator (`background/mod.rs`) — every per-weather magic constant lives in the spec (Snow diverges: `seed_mult` 11 not 7, a distinct `sx_mult`, flat single-pixel flake); the unification is pixel-verified byte-identical per weather (#92). **Forcing a weather:** `weather_state` checks a thread-local set by `pixel_painter::force_weather(Option<&str>)` (public) → snapshot's `--weather <kind>`; production never sets it, so live rendering is unchanged. This is the single chokepoint — every weather derivation funnels through `weather_state`, so the override covers them all. Drives the site weather gallery (`just gen-media` → `scripts/gen-media.py` renders `weather_<kind>.png` from the `weather` job in `scripts/media.json`, whose matrix `@`-refs `site/src/weather.json`).
- "How does the meeting room come alive (sitting + group talk)?" → `core::layout::compute_waypoints` pushes per-room **slots** into `layout.waypoints`: **3** `MeetingSofa` seats per sofa (`dx ∈ {-6,0,+6}` on the 20px sofa) + 2 `MeetingStand` spots per table, each carrying a `Facing` (north seat faces South/viewer, south faces North/away; stands face the table centre) and a `room_id`. They're ordinary waypoints, so the existing `waypoint_index_for_cycle` sends idle agents there emergently (no coordinator). Render (`pixel_painter` AtWaypoint dispatch): `MeetingSofa` Facing::North → `back_couch` sprite, else front `seated`; `MeetingStand` → `standing` + `flip_x` by facing. Group chat: `tui::chitchat` is venue-keyed — `VenueKey::Room{floor,room_id}` merges all of a room's slots into ONE conversation with `participants: Vec<AgentId>` round-robin (refreshed each frame for join/leave). The **lounge couch** is also 3 seats (`WaypointKind::Couch`, `room_id = None`) and ALSO group-chats: its seats collapse to one venue via `chitchat::venue_wp_idx` (the first couch's `wp_idx`) rather than overloading the meeting-only `room_id` (which indexes `layout.meeting_furniture` — the `Vec<MeetingFurniture { sofas: [Point;2], table }>` that replaced the parallel `meeting_sofas`/`meeting_tables` Vecs); pantry/vending/printer stay single-point `Waypoint`. The couch sprite/rug/side-table paint ONCE, centred on `SceneLayout::couch_sprite_center` (3 seat waypoints would triple-paint). Both sofas share the 20px `meeting_sofa` sprite. A seated occupant's y-sort key uses `anchor_no_breath.y` (not the breathing anchor) so the ±1px breath can't flip the sofa over its sitter. The whole pipeline is covered headlessly by `tui_renderer/harness.rs::meeting_room_fills_and_hosts_group_chitchat`.
- "How does the thinking pose work?" → `core::pose::derive` returns `Pose::SeatedThinking` when an Idle agent's `last_event_at` is within `THINKING_WINDOW_SECS = 20s` AND `last_event_at > created_at` (excludes freshly spawned agents). Renders as the `seated` sprite + screen glow, with **no overhead indicator** — the pose holds the agent at its desk for the 20s window before it wanders/sleeps. (The old animated `···` thinking-dots `effects::paint_thinking_dots` were removed as visually distracting; only the silent seated pose remains.) Screen glow only paints when the agent's derived pose is seated (precomputed pose map avoids double A*).
- "How do tooltip stats work?" → `AgentSlot.tool_call_count` increments on `ActivityStart` (excludes Task delegation). `AgentSlot.active_ms` accumulates on the next `ActivityStart` (measuring the previous span) and on `expire_pending_idles` (measuring to `pending_idle_at`, not `now`, to avoid grace-window inflation). Tooltip shows `⏱ duration · N calls · X% active`. Fresh agents (<5s) show `--% active`. (The accounting lives in the reducer — `pixtuoid-core/state` — the tooltip render is `widgets/tooltip.rs`.)
- "How does the coffee machine Easter egg work?" → `hit_test.rs::hit_test_coffee_machine` checks if a click falls on the coffee machine section of the pantry counter sprite (x offset 11–18 for large, 8–13 for small). Hover shows `widgets/tooltip.rs::paint_coffee_tooltip` ("☕ Buy Ivan a coffee"), click opens BMC via `open::that`. Both take `&Layout` (cached on `TuiRenderer`).
- "How do furniture hover tooltips work?" → `hit_test.rs::hit_test_furniture` tests mouse coords against all layout positions (desks, waypoints, plants, wall decor, pod decor, meeting sofas/table, coat rack, doormat, water cooler, trash bin, elevator). Returns `Option<&'static str>` label. `widgets/tooltip.rs::paint_furniture_tooltip` renders it. Checked after agent tooltip and coffee machine in the draw closure priority chain.
- "How do the corridor appliances work?" → Vending machine (4×6) and printer (5×4) are painted as y-sorted `Drawable` variants in `pixel_painter/drawable.rs`. Both are `WaypointKind` variants so idle agents can wander to them. Placement is conditional on corridor height (vending ≥10px, printer ≥9px). Positions stored as centre-point waypoints (same convention as Pantry/Couch).
- "How does per-agent motion state work?" → `tui::motion::MotionState` (in `motion/mod.rs`) holds all per-agent walk-timing: `entry: Option<(SystemTime, WalkProfile)>` (door→desk; a 2-tuple — no `from`); `exit: Option<WalkLeg>` (`WalkLeg { started_at, profile, from }`) — `from` is the agent's position when `exiting_at` fires (its current wander location if out on a trip, else the desk anchor) so the exit walk starts where the sprite actually is, not by teleporting to the desk; `snap_back: Option<WalkLeg>`; `walk_path: Option<WalkPathSnapshot>` — the **frozen A\* polyline** for the current walk leg (re-snapshotted only when the `(from, to)` endpoints change, and only for genuinely cornered routes >2 points). All profiles freeze *duration*; `walk_path` freezes *geometry*. Plus the elastic wander timeline — `wander_phase` (`WanderPhase`: Seated/WalkingOut/AtWaypoint/WalkingBack), `wander_phase_started_at`, `wander_profile`, `wander_cycle_n`. Each `FloorCtx` owns `pub motion: HashMap<AgentId, MotionState>` and `pub door_anim_max_ms: u64` (max in-flight entry/exit `duration_ms + pause_ms`, updated each frame via `recompute_door_anim_max_ms()`, replaces the hardcoded `ENTRY_ANIMATION_MS` in door cosmetics). Evicted via `fctx.motion.retain(|id,_| scene.agents.contains_key(id))`. `octile_path_len(&[Point]) -> u32` sums per-segment `octile_distance`.
- "What is the elastic wander timeline?" → `advance_wander()` in `motion/mod.rs` drives the cyclic wander via explicit per-phase clocks anchored to `wander_phase_started_at`. Walk legs (WalkingOut/WalkingBack) use `physics::walk_profile` — variable duration proportional to path length; Seated/AtWaypoint dwell is **absolute per-spot**: `seated_dwell_ms(id)` for the desk beat (15-30s) and `dwell_ms(kind, id)` for the waypoint beat (sofa/meeting seat 20-40s, pantry 10-18s, vending/printer 4-8s). The same `dwell_ms`/`seated_dwell_ms` are read by core's stateless `idle_pose` for overlay coherence, BUT `idle_pose`'s own cycle period uses fixed estimates (`WANDER_WALK_EST_MS`/`WANDER_DWELL_EST_MS`/`est_wander_cycle_ms`) since exact coherence is impossible (core has no router). Total cycle wall-time is elastic — it grows with actual path length — but `wander_cycle_n` is deterministic so destination selection is unchanged from core's stateless wander. **Idempotency**: transitions fire only when `now > last_advanced_at`. **Bootstrap**: fresh Idle agents (UNIX_EPOCH sentinel on `wander_phase_started_at`) fast-forward `cycle_n = elapsed_idle / est_wander_cycle_ms(id)`; the phase clock anchors at `now` (clean Seated start) so the machine never rushes through expired legs (a desk↔waypoint teleport). **Stale resume**: the TRIGGER is `now - last_advanced_at > stale_resume_gap_ms(id)` (7-13s — a frame-cadence-vs-frozen-floor sentinel, NOT a dwell detector: on-screen `advance_wander` runs every ~33ms even during a 40s lounge dwell, so the gap never approaches it; only an off-screen floor or a pause trips it). On trip it re-runs the same analytic bootstrap instead of replaying the whole backlog one transition per frame. Do NOT raise the trigger to "max dwell" — that would let 13-60s off-screen gaps replay. **Seated render authority**: in `tui::pose::derive_with_routing` the `WanderPhase::Seated` arm returns `Pose::SeatedIdle` DIRECTLY — it must NOT fall through to core's stateless `idle_pose`, whose independent fixed-fraction timeline drifts from this machine and would render the agent at a waypoint while the tui clock says Seated (a teleport). Don't "simplify" the Seated arm back to a fallthrough.

## When refactoring

The render path is exercised by the headless harness (`tui_renderer/harness.rs`,
~65 tests) plus dense `motion/tests.rs` + `pose/tests.rs` unit suites with a real
A\* router and overlay churn. Changes to `derive_with_routing`, `MotionState`, or
the pixel passes should add or update a frame-by-frame continuity guard — the
flash/teleport/replay regressions documented above all came back as failing
tests first.
