## Files changed

EDIT
ui/eink/EinkRefreshZone.kt
New `RefreshMode.FULL_ONCE` variant — semantic "one clean GC16 flash on appearance, then stay quiet". Maps to the same vendor call as `FULL` but separates intent.
NEW
ui/eink/EinkZonePolicy.kt
Single-source-of-truth refresh policy matrix. `Zone` enum captures mode + debounce per zone so call sites stop hardcoding waveform constants.
NEW
ui/eink/EinkScenarios.kt
Five canonical multi-session fixtures (1×–5× sessions, mixed agent types) for `@Preview` and screenshot tests.
EDIT
terrarium/renderer/EinkRenderer.kt
Kobo / Tolino / KOReader fallback added to `EinkRefreshHelper.requestFullRefresh` via `SystemProperties.set("sys.eink.update", "GC16")`. Falls through to `view.invalidate()` on unsupported devices.
EDIT
ui/screen/EinkMonitorScreen.kt
ATTENTION extracted as its own `EinkRefreshZone` with `FULL_ONCE` policy. All zone call sites (5 in landscape, 3 in portrait) now read mode + debounce from `Zone.CHROME / ATTENTION / CONTEXT_FAST / STATUS_SLOW / TIMELINE` instead of inline literals.

## The refresh matrix — one source, six zones

Before: every zone hardcoded its mode/debounce inline at the call site (`RefreshMode.A2, 200`). Six zones × landscape + portrait = 11 duplicates that drifted over time. After: every call site reads from `Zone`.

Zone | Mode | Debounce | Trigger
--- | --- | --- | ---
CHROME | A2 | 200 ms | session list / agent states
TERRARIUM | animated | per-frame | creature animation loop
ATTENTION | FULL_ONCE | 80 ms | awaiting state appears
CONTEXT_FAST | A2 | 200 ms | tool / option changes
STATUS_SLOW | DU | 2000 ms | usage / health / models
TIMELINE | A2 | 300 ms | append-only entry count

## Why ATTENTION moves to its own zone

In the old code the `featuredAttention != null` branch lived inside the Context+Status `EinkRefreshZone` on `RefreshMode.A2`. Two problems:

1. The prompt panel inherited residual A2 ghosting from neighbouring tool / gauge updates.
2. Onset of `AWAITING_PERMISSION` never forced a clean GC16 flash — the prompt just appeared in whatever waveform mode the zone happened to be in.

    // after — EinkMonitorScreen.kt
    if (featuredAttention != null) {
        EinkRefreshZone(
            mode        = Zone.ATTENTION.mode,        // FULL_ONCE
            debounceMs  = Zone.ATTENTION.debounceMs,  // 80 ms
            triggerKey  = featuredAttention,
        ) { /* permission / option panel */ }
    } else if (hasContext || showStatusPanel) {
        // CONTEXT_FAST / STATUS_SLOW — never sees the prompt
    }

## Driver port — what landed

The proposal called for a brand-new `EpdRefreshController` interface. Reading `EinkRefreshHelper` showed the helper IS the port — Rockchip + Onyx already covered. Minimal change instead: add a third fallback for Kobo / Tolino, document the chain.

    fun requestFullRefresh(view: View) {
        // 1. Rockchip RK35xx (Crema S, Xiaomi Reader)
        if (tryRockchipRefresh(view, RK_EPD_FULL_GC16, sendFullFrame = !einkColorEnabled)) return

        // 2. Onyx Boox (Qualcomm)
        try { onyxClass.getMethod("requestScreenUpdate", View::class.java).invoke(null, view); return }
        catch (_: Exception) {}

        +   // 3. Kobo / Tolino / KOReader — system property bridge
        +   if (tryKoboRefresh(view, koboMode = "GC16")) return

        // 4. Fallback: standard invalidate
        view.invalidate()
    }

## Five canonical multi-agent scenarios

The design canvas illustrated five session topologies; they now exist as `EinkScenarios` fixtures consumable from `@Preview` parameters and screenshot tests.

01 · TYPICAL
typical
Claude×2 Codex OpenClaw
Everyday workload — primary Claude processing, peers idle.
02 · CLAUDE-HEAVY
claudeHeavy
Claude×3 Codex OpenCode
Three Claude sessions concurrent — Opus, Sonnet, Haiku.
03 · AWAIT-MIXED
claudeAwaiting
Claude AWAIT Claude Codex OpenClaw
One Claude blocked on permission while peer keeps processing — the FULL_ONCE case.
04 · CODEX-FOCUSED
codexFocused
Codex×2 OpenCode Claude
Claude not the primary — chrome must not assume Claude-first ordering.
05 · SOLO
soloOpenCode
OpenCode
Single sparse session — collapsed chrome, terrarium hosts one creature.

## Migration order — three commits

1. RISK · LOW · ADDITIVE
   Add primitives, no UI change
   - Add `RefreshMode.FULL_ONCE` in `EinkRefreshZone.kt`
   - Create `EinkZonePolicy.kt` with the `Zone` enum
   - Create `EinkScenarios.kt` fixtures
   - Add Kobo fallback in `EinkRefreshHelper.requestFullRefresh`
2. RISK · LOW · MECHANICAL
   Wire `Zone.*` into `EinkMonitorScreen`
   - Replace inline `RefreshMode.A2, 200` with `Zone.CHROME.mode, Zone.CHROME.debounceMs`
   - Cover all 5 landscape + 3 portrait zone call sites
   - Behavior identical — pure refactor
3. RISK · MEDIUM · USER-VISIBLE
   Extract ATTENTION zone
   - Pull `featuredAttention != null` branch out of Context+Status zone
   - Wrap in own `EinkRefreshZone(Zone.ATTENTION.mode, ...)`
   - QA: permission prompts on Crema S + Pantone 6 must arrive ghost-free

## Risk summary

Path | Kind | Risk
--- | --- | ---
`ui/eink/EinkRefreshZone.kt` | add enum variant | LOW
`ui/eink/EinkZonePolicy.kt` | new file | LOW
`ui/eink/EinkScenarios.kt` | new file (test only) | LOW
`terrarium/renderer/EinkRenderer.kt` | add fallback driver | LOW
`ui/screen/EinkMonitorScreen.kt` | extract ATTENTION zone | MEDIUM

Drop-in files live under `patches/` in this project. `patches/new/` contains the three new files; the other three are full pre-edited copies of the originals so you can diff them against your working tree.