# Bottom panel Stage 2 — the GPU panel band (framing)

**Revision 7 — Stage 2B-3 review round 1 corrections; the underlying
Stage 2 framing remains APPROVED 2026-07-27. 2A (#177) and 2B-1 (#184) are both
merged, and 2B-2 is the next slice. Ground truth: canonical `main` @
`6bee09d`, where protocol schema support is `v6..=v21` while the
production server-first handshake still deliberately advertises v20.**
Revisions 1–4 were pre-implementation; rev 5 recorded the three-way
slice of Stage 2B after its first slice was already built; rev 6
corrects that slice's mixed-version and gate contracts. **No revision
bump accompanies 2B-1's merge** — the status sentence and ground-truth
anchor above are landed-state facts, and no design decision in this
document changed when #184 landed.

Stage 1 (#155, merge `e745068`) gave pmacs window placement, window
parameters, TUI side windows, the divider, and the adopter `display`
opt-in. It deliberately set `FrontendView::panel_capable = false` for
every semantic session, so a GPU frontend silently falls back to the
non-side target. **Stage 2 flips that bit, under an exact negotiated
rule, and earns the right to.**

This document is the re-framing `docs/bottom-panel-framing.md` (rev 4)
§2 requires before Stage 2 is implemented. It does **not** restate the
parent's decisions or replace its acceptance criteria. It records the
re-scout against current `main`, closes the four scout obligations
review round 1 required, and fixes what round 1 found wrong.

**Inherited reading, all of which remains authoritative:** parent
Q#BP8 (the band), Q#BP9 (protocol), **Q#BP14 (the primary-document
projection contract and its census classification)**, **Q#BP14a (panel
input gating is per-window)**, Q#BP14b (focus chrome and per-window
overlay routing), Q#BP15 (`PanelFrame` lifecycle), Q#BP15a (three
geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and
**parent acceptance criteria 37–55**.

## 0. Revision history

### 0.-1 Rev 6 → rev 7 — Stage 2B-3 review round 1, three stated
contracts corrected

- **R7-1 (P1) — the stable-probe decision covers all THREE cell
  computations, not only the declaration.** §5.3 decided that the panel
  geometry *declaration* resolves its advance from
  `probe_mono_advance` rather than `State::mono_advance`. That wording
  let an implementation declare with the probe while *painting* and
  *hit-testing* with the document-dependent advance — which is what
  happened, and which means the daemon's column count, the painted
  column positions, and the cell a click resolves to are three
  different grids. The decision now reads: **the panel's advance is
  resolved once, cached behind the declaration, and used by the
  declaration, the painter, and the hit test.** They agree by
  construction rather than by three call sites choosing correctly.
  A test that asserts only the declaration cannot see this, so §7.2.3
  A2B-3 gains the painting and hit-testing halves explicitly.
- **R7-2 (P1) — the x = 0 full-width contract governs the band RECT,
  not only `total.cols`.** Parent Q#BP15a already states that
  `total.cols` describes "the full-width panel grid beginning at x=0;
  document `TEXT_LEFT`/gutter padding is unrelated". Rev 6 left that
  as a statement about the *declaration*, and an implementation
  inset both the declared width and the painted rect by `TEXT_LEFT`.
  The contract is restated here as covering the band's content
  rectangle: **origin x = 0, full surface width**, with the fractional
  right-edge remainder painted as band background that maps to no cell
  and emits no `PanelPointer`.
- **R7-3 (P2) — `GoodbyeReason::VersionMismatch { server }` is the
  instance's `PROTOCOL_VERSION`, never the advertised baseline.** Once
  §3.5's activation split the two, a daemon reporting its baseline told
  a frontend it topped out at 20 while it in fact spoke 21 — the
  inverse of the upgrade advice the reason exists to give. The wire
  field's own documentation already said "the instance's
  `PROTOCOL_VERSION`"; this records that the split makes it load-bearing
  rather than incidental. A *frontend* raising the same reason locally
  can only report the baseline it was handed.
- **R7-4 — criterion 54's gate is named.** The panel-hosted
  `--headless-probe` run lives in
  `bottom_panel_stage2b_gpu_acceptance` as
  `a54_real_daemon_real_pty_and_headless_gpu_render_one_panel_hosted_terminal`,
  and it drives the real `display = "panel"` adopter opt-in rather than
  opening a terminal and moving it. Opening first and moving after
  leaves the buffer displayed twice, the document window still
  projecting it as a full-window terminal, and the acceptance unable to
  tell a panel-hosted child from a document one.

### 0.0 Rev 5 → rev 6 — PR #184 review round 2, four findings closed

- **R6-1 (P1) — v21 is reserved, not advertised, in 2B-1.** The
  protocol's handshake is server-first. An existing v20 TUI or GPU
  frontend rejects a `Hello { protocol_version: 21 }` before it can
  send an `AttachRequest`, so rev 5's claim that a v21 daemon and v20
  peer "still negotiate 20" was impossible. 2B-1 therefore extends the
  schema and accepted-version ladder to v21 while the production daemon
  continues advertising v20. A real-daemon acceptance emulates the
  shipped v20 rejection point and then requires the attachment to reach
  its initial grid. **2B-3 owns both a compatibility-preserving
  activation mechanism and the production move to v21; it may not
  simply change the unsolicited `Hello` to 21.**
- **R6-2 (P2) — durable protocol claims move with the wire.**
  `COHERENCE.md` and `docs/agent-handoff.md` now distinguish v21 schema
  support from the still-v20 production handshake.
- **R6-3 (P2) — the gate contract names the actual decomposition.**
  §9 now names 2B-1's
  `bottom_panel_stage2b_protocol_acceptance` suite and the exact planned
  daemon/GPU suite names for 2B-2 and 2B-3 instead of the nonexistent
  `bottom_panel_stage2b_acceptance`.
- **R6-4 (P2) — "one byte over" means exactly one.** The panel and
  copied terminal boundary fixtures replace a one-byte cluster with a
  two-byte cluster, and each independently asserts a total of
  `limit + 1`.

### 0.1 Rev 4 → rev 5 — the three-way slice of 2B (not a review round)

This revision changes no decision. It splits one approved
implementation slice into three and reallocates the acceptance
criteria across them.

- **R5-1 — why.** Rev 4 §9 scoped 2B as a single PR: v21 protocol,
  daemon panel projection, GPU band, and the negotiated
  `panel_capable` flip. Implementation showed that to be roughly four
  thousand lines spanning `pmacs-protocol`, `src/daemon.rs`, and
  `pmacs-gpu` — three review surfaces with different failure modes, in
  one diff. The same argument that produced 2A/2B applies again one
  level down, and it is the argument this arc has already accepted
  twice (Lean 4 stages 3a/3b and 4a/4b).
- **R5-2 — the boundary rule.** A slice ends where the next thing to
  build has a different *authority*: the wire format, the daemon that
  produces frames, and the frontend that paints them. Each slice is
  independently reviewable against a subset of the parent criteria,
  and each is additive — no slice makes a previously-passing assertion
  fail.
  **Criteria that span a boundary are named in every slice they touch,
  with their half stated**, rather than assigned wholesale to one. The
  clearest case is parent 39: its shared-validation and
  transport-budget halves are wire properties provable in 2B-1, while
  "the previous valid frame is retained" and "a duplicate does no
  work" are receiver-state properties that need the epoch machine and
  land in 2B-2.
- **R5-3 — this revision is retroactive for slice 1, and that is a
  process defect worth recording.** `bottom-panel-stage2b` already
  carries the v21 protocol layer (three commits, one review round
  closed) written before this revision existed. The workflow is
  framing → approval → branch → implement; slice 1 inverted it. The
  slicing decision was sound, but it was taken in code and discovered
  in the branch rather than proposed in the document, which is exactly
  how a stage's scope drifts without anyone deciding that it should.
  Rev 5 exists to put the decision back where it belongs before slices
  2 and 3 are written.
- **R5-4 — two of the three slices ship dark, deliberately.** Nothing
  in 2B-1 or 2B-2 is reachable by a user: `panel_capable` stays
  `false` for every negotiated semantic session until 2B-3. Rev 5
  incorrectly described that posture as a production v21 negotiation
  that remained compatible with v20 clients; rev 6 R6-1 supersedes
  that claim. The actual dark posture keeps the server-first
  production handshake on v20 while the v21 schema is reserved. This
  is the same posture 2A took ("seam adoption that becomes
  load-bearing in 2B"), and it means the arc must not stall between
  2B-1 and 2B-3. Recorded here so a stall is visible as a decision
  rather than inherited as a default.

### 0.2 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all closed

- **R3-1 (blocker).** Rev 3's three-boundary model was right but its
  call-site table was wrong in five places, and each error was a real
  defect: `:6140` is **document completion placement** (classified
  status-owned, which would let completion overlap the panel);
  `:7195`/`:7212` are the two **status text bounds** (classified
  document-owned); `:7351` clips **global minibuffer candidate glyphs**
  to the dropdown's band anchor (classified document-owned, which would
  clip them against the document boundary); `:8561` (**document edge
  scrolling**) was missing entirely, leaving it tied to the old bottom;
  and `:8077` was described as completion placement when it is **caret
  clipping** (its class was right, its label wrong). §5.3's table is
  rebuilt from the full census and every row is verified against the
  source.
  **Root cause worth recording:** rev 3's table was built from a
  `grep | head -20` over 29 matches. The truncation is exactly why
  `:8561` vanished. The census is now stated as 20 production sites +
  1 definition + 8 test sites = 29, so a future reader can check the
  arithmetic instead of trusting the list.
- **R3-2 (high).** The three equations permitted negative coordinates
  on a surface shorter than its chrome, where today's
  `text_area_bottom` clamps with `.max(0.0)`. All three are now
  explicitly clamped, preserving the current helper's behavior.
- **R3-3 (medium).** §5.1's "exact split" omitted `validate_cells`'s
  `cell.attachment.is_some()` rejection. It is now classified — and
  **shared**, with the reasoning pinned.

### 0.3 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed

- **R2-1 (blocker).** Rev 2's "one document-bottom seam" conflated two
  boundaries that must **diverge** once a panel exists. Several sites it
  named are not document-bottom consumers at all: the status-band
  background (`main.rs:5908`) must stay at the physical window bottom,
  the status text buffers (`:3175`, `:3185`, `:6601`, `:6607`) consume a
  *height* and never a bottom coordinate, and status text placement
  (`:7134`) sits inside an unchanged band. §5.3 now splits the single
  value into **three** named boundaries, classifies every existing
  `text_area_bottom` call site, and adds the contrast assertion that
  catches a uniformly-wrong implementation moving both together.
- **R2-2 (high).** `accept_frame_geometry -> bool` cannot distinguish
  *advanced* from *accepted duplicate* from *rejected*. It now returns an
  explicit three-valued result. The exhaustion wording also permitted
  retaining stale geometry, which is not fail-closed: §3.1 now clears the
  authoritative declaration and reconciles to hidden, and adds the
  frontend-side terminal latch.
- **R2-3 (high).** Parent acceptance 52 was assigned wholly to 2A, but
  2A has no semantic panel projection — it can only prove the extracted
  painter accepts an explicit `None`. 52 is now also reasserted in 2B,
  where the contract becomes production-reachable.
- **R2-4 (medium).** §9 names the four touched acceptance suites
  explicitly rather than relying on "standing suite".
- Both §8 open items are decided (§5.3): `BASE_DIVIDER_HEIGHT = 4.0` at
  scale 1.0, and `TEXT_TOP` stays unscaled.

### 0.4 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed

- **R1-1 (blocker).** Rev 1 said all 23 census reads route through
  `primary_document_window`. That contradicts Q#BP14, which routes only
  the **Projection** class (#1–#12, #21–#22) that way and leaves focus,
  input, chrome, and bell consumers on their own authorities. Rev 1's
  rule would have broken remote-op validation, `DispatchIdle`,
  presence, focused search/menu/completion routing, and bell ownership.
  §3.2 now restores all four classes; §7's criterion pins them
  separately. The inherited-reading list above gains Q#BP14 and Q#BP14a.
- **R1-2 (blocker).** Rev 1 treated the three `src/statusline.rs`
  active reads as one disposition. Only `:644` selects the wrong
  window; `:629` and `:675` must keep tracking **actual focus**. §3.3
  is rewritten and the criterion states the required behavior instead
  of routing focus away.
- **R1-3 (high).** The `panel_capable` flip needed an exact attach
  rule, not "for semantic sessions". §3.5 states it: **v21-or-later
  negotiated authenticated semantic session only**.
- **R1-4 (high).** Option 1 accepted, but the epoch needed a state
  machine, split APIs, and a fail-closed allocator. §3.1 now carries
  the transition table and the API split. Rev 1's phrasing "rejects a
  lower-or-equal epoch carrying different data" was itself wrong — a
  lower epoch carrying *identical* data is still stale.
- **R1-5 (high).** Rev 1's eleven draft criteria silently omitted
  parent 37–55. §7 now declares the parent list authoritative, maps it
  to 2A/2B, and adds only refinements. The painter-extraction criterion
  pins cursor, `view_top`, and passive-window state, not just cells.
- **R1-6.** All four scout obligations are closed in §5.
- **R1-7.** The coherence statement understated journey impact and
  overclaimed on background work. §6 names journey steps 7–10 and
  narrows the §9 claim.
- **R1-8.** Factual corrections in §1 and §3.2.

## 1. Anchor re-scout

| Parent anchor | Now at | Verdict |
| --- | --- | --- |
| `paint_frame` returns cursor separately (`editor.rs:2833`) | `src/editor.rs:3171` | Holds |
| Cursor-visible prep (`editor.rs:2883-2935`) | `src/editor.rs:3249+` | Holds; Stage 1 inserted work above it (§2) |
| Per-window paint body (`editor.rs:2937-3040`) | after `src/editor.rs:3260` | Holds |
| `fold_map_for_window` gates on the **active** frontend (`editor_core.rs:566`) | `src/editor_core.rs:734`, gate at `:738` | Holds |
| Stale "semantic session never enters `paint_frame`" (`window.rs:339`) | `src/window.rs:562` | Holds, still stale; now embedded in a longer `fold_projection` doc block, so the edit is a paragraph rewrite |
| `Mouse` is contractually the grid path (`daemon.rs:3122-3130`) | `src/daemon.rs:3123` | Holds |
| Permanent `24×80` placeholder (`attach.rs:420-429`, `:573-577`) | `pmacs-gpu/src/attach.rs:577`, single site | Holds |
| Byte pin `InstanceMessage::InitialTargetResult` | `pmacs-protocol/src/message.rs:1145` | Holds — still the enum's final variant |
| Byte pin `FrontendEvent::TerminalPointer` | final variant of its enum | Holds |

**Protocol was still v20 at this re-scout**; no intervening PR had
bumped it. Q#BP9's conditional resolved: **Stage 2 reserves v21**.
Rev 6 R6-1 adds the server-first compatibility constraint discovered
during 2B-1 review.

Fifteen PRs merged between the parent's last re-scout (`47581f4`) and
this one: #149, #150, #152–#155, #158–#166. Nothing in the parent's
mechanical model was falsified by any of them.

## 2. What Stage 1 already built for Stage 2

- `DeclaredFrameGeometry { geometry_epoch: u64, total: CellSize }`
  (`src/window.rs:522-528`), held as
  `FrontendView::frame_geometry: Option<_>` (`:589`) where `None` means
  **unknown** — Q#BP15a's "unknown is first-class", already landed.
- `EditorState::sync_frame_geometry` (`src/editor.rs:877-882`) →
  `declare_frame_geometry` + `reconcile_panel_layout`, driven from two
  daemon sites gated on `panel_capable_for` (`src/daemon.rs:1882-1883`
  attach, `:1972-1973` resize).
- `paint_frame` declares geometry itself (`src/editor.rs:3187`), before
  the statusline fan-out and before the long mutable core borrow.
- `StatuslineEvaluationTarget` (`src/statusline.rs:212-226`) is already
  a two-variant enum, so Q#BP8's fan-out generalization is an added
  variant, not a refactor.
- `primary_document_window` (`src/editor_core.rs:2830`) and
  `primary_document_buffer` (`:2845`).

## 3. Findings and decisions

### 3.1 Q#BP2S1 — epoch ownership, resolved: frontend-owned, with an exact state machine

**Decision: option 1.** The epoch is owned by the frontend for
negotiated semantic-panel sessions. The deciding argument is one rev 1
missed: **a font or scale transaction can require invalidating an old
`PanelFrame` even when the derived `CellSize` is identical.** Daemon
value dedup cannot detect that case, because the cell totals it
compares are unchanged while the pixels behind them are not.

The landed allocator conflicts in three ways
(`src/editor_core.rs:3155-3172`): it allocates the id itself, it
early-returns when `total` is unchanged (value dedup), and it uses
`saturating_add`, which is neither wrapping nor fail-closed — it pins
at `u64::MAX`, after which two different geometries share one id.

**Acceptance rules for a semantic declaration:**

| Incoming declaration | Result |
| --- | --- |
| epoch **greater** than stored | Accept, store **verbatim**, even if `total` is unchanged |
| same epoch, same `total` | Idempotent no-op |
| same epoch, **different** `total` | Reject |
| **lower** epoch, any `total` | Reject |

The last row is deliberate and corrects rev 1: a lower epoch carrying
identical data is still stale and must not be accepted.

**API split.** Two methods, not one method with an optional epoch:

- `declare_frame_geometry(fid, total)` — the **grid/LOCAL** allocator.
  Keeps value dedup (correct there: cells are the unit, and an
  unchanged grid means an old frame is still valid under unchanged
  metrics). Changes from `saturating_add` to **checked** allocation
  with an explicit fail-closed exhaustion arm.
- `accept_frame_geometry(fid, geometry_epoch, total) -> GeometryUpdate`
  — the **semantic** path. No value dedup; applies the table above
  verbatim.

An ambiguous single method with an `Option<u64>` epoch is rejected
explicitly: it would let a future caller silently take the wrong regime.

**The result is three-valued, not a boolean.** A boolean cannot
distinguish the three outcomes the caller must act on differently:

```rust
enum GeometryUpdate {
    /// Epoch advanced: stored verbatim. Run panel reconciliation.
    Advanced,
    /// Same epoch, same total: already current. Do no work.
    Duplicate,
    /// Same epoch with different total, or a lower epoch: stale or
    /// conflicting. Drop the event before any reconciliation.
    Rejected,
}
```

`Advanced` reconciles, `Duplicate` returns without touching panel
state, and `Rejected` drops the event. Collapsing `Duplicate` into
either neighbour is a defect in one direction or the other: folded into
`Advanced` it reconciles on every repeated declaration, folded into
`Rejected` it would log or surface a stale-event condition that never
happened. (If a boolean is kept for a narrower internal caller, it must
be named `advanced`, never `accepted` — `Duplicate` *is* accepted.)

**Initial epoch.** The frontend's first declaration after attach
acceptance carries epoch `1`. `0` is reserved as "never declared" and
is rejected on the wire.

**Exhaustion fails closed on both sides, and rev 2's wording did not.**
Saying the panel "stays at its last valid geometry" is not fail-closed:
if the real frame resizes after the allocator is exhausted, the daemon
would keep painting a panel sized to geometry that no longer describes
the frontend.

- **Grid/LOCAL path.** On checked-allocation exhaustion, **clear** the
  authoritative `frame_geometry` (back to `None` = unknown) and
  reconcile. Unknown is already non-presentable under Q#BP2b, so the
  panel hides. Stale geometry is never retained.
- **Frontend path.** On exhaustion the frontend sets a **terminal
  latch** for the life of the session: it sends no further geometry,
  and — critically — an old matching `Present` **cannot** make the band
  reappear, because the latch suppresses paint and hit-testing
  independently of frame validity. Only a fresh session (reconnect)
  clears it. Without the latch, a retained `Present` whose epoch still
  matches the last declaration would resurrect a band under geometry
  the frontend has disowned.

### 3.2 The census is classified, and it is mostly unrouted

**Correction to rev 1.** Q#BP14 routes only the **Projection** class
through `primary_document_window`. Rev 1's "all 23 reads" was wrong and
would have broken five subsystems. The four classes, restored:

| Class | Census items | Authority |
| --- | --- | --- |
| **Projection** | #1–#7, #9, #10, #12, #21, #22 | `primary_document_window` / `primary_document_buffer` |
| **Projection + focus** | #8 (document `Pointer`), #11 (full-window `TerminalPointer`) | Align the primary document window **and then activate it** — the one place the two legitimately move together |
| **Focus / input** | #13 (remote-op validation), #14 (`dispatch_idle_for`), #15 (presence), #23 (remote-op application) | The frontend's **actually focused** window. Q#BP14a: gating is per-window, never per-buffer |
| **Focus chrome / surface-routed** | #16–#19 (search, menu, minibuffer, completion) | Q#BP14b's routing table — the currently owned surface, with authoritative clears for the other |
| **Focus / session** | #20 (terminal bell drain) | Per-session counter; the **focused** window chooses which session may drain |

Rerouting any of the last three classes to the document is a defect,
not a simplification: it would break remote-op validation and
application, `DispatchIdle`, presence, focused search/menu/completion
routing, and bell ownership.

**How much is already routed.** `primary_document_window` has **four**
references in `src/` and **two production paths**: directly at
`src/daemon.rs:1639` (#148's initial-target bootstrap, Q#BP11b), and
through `primary_document_buffer` at `src/daemon.rs:2998`, which is
census **#22** and carries a comment naming it. So one census item is
routed and the Projection class is otherwise open. For scale, `src/*.rs`
still holds ~80 non-test direct `.active` reads on top of the
`active_window*` / `active_buffer*` helper family
(`src/editor_core.rs:663-967`).

This is not a Stage 1 defect — with `panel_capable = false` no semantic
frontend can hold a side window, so the unrouted Projection reads are
unreachable from the GPU. It does mean **classified census routing is
the bulk of Stage 2**, which is why it is Stage 2A.

### 3.3 The three statusline reads have two dispositions, not one

All three sites are real, but only one is wrong:

- `src/statusline.rs:644` — `.get(&view.active)` **selects the wrong
  window** when a panel is focused. This is the Projection read (#12).
- `src/statusline.rs:629` and `:675` — `active: window_id ==
  view.active` **must continue tracking actual focus**. Three reasons:
  grid contexts need a truthful `active`; post-callback revalidation
  must notice a focus change; and parent acceptance 42 explicitly
  requires that a document provider may observe `active = false` while
  the panel is focused.

**The new semantic-layout target** therefore captures the **primary
document window plus the visible side window**, marks each context
`active` iff its `window_id == view.active`, invokes each provider
**exactly once**, and **invalidates the entire evaluation** if a
callback mutates layout or focus. Unprojected document splits run no
callbacks (Q#BP8). Route the primary-document result to semantic
`StatuslineSegments` and the side result to the panel mode line.

### 3.4 Fold projection

Unchanged from Q#BP17, with the anchor corrected: the extracted painter
takes the map as a **parameter**; the panel path passes `None` when the
owning frontend's `fold_projection` is false and must never call
`fold_map_for_window`, which gates on the **active** frontend
(`src/editor_core.rs:734`, gate at `:738`) — right for command-time
reckoning, wrong for painting another frontend's panel. The stale
comment is at `src/window.rs:562`.

### 3.5 The `panel_capable` flip needs a negotiated rule

Not "true for semantic sessions". Exactly:

> `panel_capable = true` **only** for an authenticated semantic session
> that negotiated **v21 or later**.

A v6–v20 semantic frontend stays non-panel-capable and takes the
existing Stage 1 fallback: the non-side target with **every
side-specific parameter discarded**, leaving the document window
undedicated (Q#BP2c). "It receives no new events" is insufficient — if
the daemon nevertheless places that frontend's window in a side panel
it cannot render, the window becomes invisible. The gate is on
placement, not only on transport. Parent acceptance 51 pins the mixed
session. **The production daemon does not advertise v21 in 2B-1 or
2B-2.** Because `Hello` is server-first, 2B-3 must add or prove a
compatibility-preserving way to activate v21 before applying this rule;
merely advertising 21 would strand already-shipped v20 clients before
they can identify themselves.

## 4. Revisions to the parent framing

Only these; everything else stands.

- **Q#BP9 resolves to the v21 schema, with production advertisement
  held at v20 until 2B-3 supplies compatible activation.**
- **Q#BP15a's epoch ownership is specified** by §3.1's table and API
  split, replacing the parent's one-line "frontend-owned" statement.
- **Q#BP8's statusline criterion splits** per §3.3: one read reroutes,
  two keep tracking focus.
- **Q#BP17's stale comment is at `src/window.rs:562`**, and parent
  acceptance 52's reference to `:339` should be read against that.

## 5. The four scout obligations, closed

### 5.1 The shared cell-grid validator boundary

`TerminalFrame::validate` (`pmacs-protocol/src/terminal.rs:226`)
currently interleaves both concerns. The exact split:

- **Factored into the shared parameterized wire-cell-grid validator:**
  checked area (the `checked_mul` + `usize::try_from` guard), the
  `MAX_TERMINAL_VISIBLE_CELLS = 262,144` aggregate cap, cell-count
  equality against declared area, cursor-in-bounds, and
  `validate_cells`'s glyph width / continuation topology and aggregate
  glyph-byte checks.
- **Stays terminal-only:** the `MAX_TERMINAL_ROWS/COLS = 512` per-axis
  caps in `checked_area`, `validate_metadata` for title/signal/crash
  text, `validate_selection`, and the `at_bottom == (scroll_offset ==
  0)` coupling.

**Attachment rejection is shared, not terminal-only.** `validate_cells`
also rejects `cell.attachment.is_some()`
(`pmacs-protocol/src/terminal.rs:305`), and its error text reads "A
cell carries a frontend attachment, which terminals never use"
(`:190-191`) — phrased as a terminal-specific fact, which is why rev 3
missed it. **Stage 2 classifies it shared**: panels implement no
attachment rendering, so a `PanelFrame` carrying one describes a
surface the GPU would silently not draw. Shared rejection fails closed
on the producer side rather than shipping an invisible cell. The error
message is reworded away from "which terminals never use" to a
grid-neutral phrasing when it moves. If a later stage gives panels
attachment rendering, this rejection moves back to terminal-only as a
deliberate, reviewed change — not by default.

`PanelFrame` takes the shared half plus its own presence/epoch rules
and does **not** inherit the 512 per-axis cap (Bet B5'), so a 4K
small-font panel wider than 512 columns is legal while the shared area
budget still binds. Parent acceptance 39 pins exactly this.

### 5.2 The GPU outbox needs four more tags

`coalesce_kind` (`pmacs-gpu/src/attach.rs:331`) today returns four
tail-only tags: `Viewport` → 0, `Pointer{Drag}` → 1,
`TerminalPointer{Move}` → 2, `TerminalPointer{Drag}` → 3. Everything
else is `None` = lossless, counting against `OUTBOX_MAX = 8192`.

Stage 2 adds **four distinct tags**: `FrontendCellGeometry` → 4,
`PanelResizeRows` → 5, `PanelPointer{Move}` → 6, `PanelPointer{Drag}`
→ 7. Geometry is latest-wins (epochs need only increase, not be
consecutive); resize drag is latest-wins over the complete event
including its epochs. `PanelPointer` `Down`/`Up`/wheel/context stay
lossless and ordered — repeated left `Down`s are what the daemon click
state reads as a multi-click, and `Down(Right)` is the context-menu
gesture. Tail-only replacement preserves ordering across an
intervening event of any other class.

### 5.3 The pixel formula's inputs — and one trap

The formula in Q#BP15a is contract-level, not an implementation
detail, because its inputs are not all safe to adopt:

| Input | Source | Note |
| --- | --- | --- |
| `status_band_height_px` | `FontMetrics::status_band_height` (`pmacs-gpu/src/main.rs:137`) = `BASE_STATUS_BAND_HEIGHT * scale` | Safe |
| `TEXT_TOP_px` | `const TEXT_TOP: f32 = 16.0` (`main.rs:352`) | Safe; unscaled today |
| `code_line_height_px` | `FontMetrics::code_line_height` (`main.rs:131`) = `BASE_CODE_LINE_HEIGHT * scale` | Safe |
| `resolved_monospace_advance_px` | `State::mono_advance` (`main.rs:4899`) | **Unsafe to adopt blindly** |
| `divider_height_px` | `BASE_DIVIDER_HEIGHT` | **Does not exist yet** |

**The `mono_advance` trap.** `State::mono_advance` returns
`measured_mono_advance` when a `FontFacts` probe has been applied, but
otherwise falls back to **the first shaped glyph of the document
buffer** (`main.rs:4903+`). Panel column count would therefore become
**document-dependent**: two GPU frontends showing different files could
derive different `total.cols` from identical metrics, and the same
frontend's panel width could change when the document's first glyph
changes.

**Decision.** The panel geometry declaration uses a **stable normal-face
probe**, never the document sample. `probe_mono_advance(font_system,
family, metrics)` (`main.rs:323`) already exists and is exactly this: it
shapes `ADVANCE_PROBE` in a scratch buffer, independent of document
contents, dividing total run width by logical cells so ligature
substitution survives. The declaration resolves its advance from that
probe for the current family/metrics. If the probe returns `None` (the
family shapes no width), the frontend declares **zero usable geometry**
under a new epoch — the panel hides — rather than falling back to a
document sample.

**`BASE_DIVIDER_HEIGHT = 4.0`** at scale 1.0, scaled by
`FontMetrics::scale` like `status_band_height`. A 1–2 px rule is
adequate decoration but too fragile as the drag hit strip; 4 px still
reads as a rule while giving the pointer a usable target. **The entire
strip is painted with `ui.divider`, and that exact rectangle is the
hover/drag hit region** — paint geometry and hit geometry are the same
rect, so they cannot drift apart.

**`TEXT_TOP` stays `16.0`, unscaled.** It is a fixed surface inset
today, like `TEXT_LEFT` and the other paddings, while
`FontMetrics::scale` governs font-derived metrics and row chrome.
Scaling it only inside the declaration formula would disagree with the
actual renderer; scaling every renderer and hit-test occurrence is a
wholesale inset/DPI change and is **named here as separate work**, not
smuggled into Stage 2. The formula is pinned to the real unscaled inset.

Accordingly, **Q#BP15a's "all quantities use the frontend's current
scale" is narrowed**: font-derived metrics and the divider scale; fixed
surface insets keep their current units.

#### The seam is three boundaries, not one

Rev 2 asked for a single document-bottom accessor. That was wrong:
once a panel is installed, today's single value must **diverge into
three**, because some of its consumers must not move at all.

All three clamp at zero, preserving today's `text_area_bottom`
`.max(0.0)` behavior — without the clamps a surface shorter than its
own chrome yields negative coordinates, and the "exact formula" stops
being exact precisely where it matters most:

```
status_band_top          = max(0, surface_height - status_band_height)

geometry_capacity_bottom = max(0, status_band_top - divider_height)
                           // divider reserved even while absent

document_text_bottom     = max(0, status_band_top
                                  - installed_panel_height
                                  - installed_divider_height)
```

`geometry_capacity_bottom` is what Q#BP15a's asymmetry already
requires: the divider is subtracted **for sizing purposes even while
the panel is absent**, which is what breaks the first-open cycle, while
the document renderer does not actually lose those pixels until a
`Present` panel is painted.

**Today `text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is all three
at once**, and its doc comment calls it "the single source for every
bottom-of-text computation" (Q#S3).

The census is **29 matches: 20 production call sites, 1 definition
(`:8490`), and 8 test sites** (`:12887`, `:12937`, `:12997`, `:13109`,
`:13793`, `:14013`, `:15306`, `:15386`). Every production site,
classified individually against the source:

**Status-owned — must stay pixel-identical at the physical window
bottom, using `status_band_top`** (8 sites):

| Site | What it is |
| --- | --- |
| `:5908` | Status-band background rect `y` |
| `:6003` | `mb_visible_window` — rows that fit **above the band** |
| `:6027` | `mb_dropdown_window` origin — dropdown grows up from the band |
| `:7134` | `status_top` for the right status group |
| `:7195` | `status_buffer` `TextBounds.top` — status text bound |
| `:7212` | `status_left_buffer` `TextBounds.top` — status text bound |
| `:7351` | Minibuffer **candidate glyph** clip, anchored to the dropdown's band origin |
| `:7922` | `status_top`, second site |

The minibuffer is **global, bufferless chrome anchored to the status
band** (Q#BP14b keeps `MinibufferPrompt` global), so all four of its
sites — `:6003`, `:6027`, `:7351`, and its `status_left_buffer` bound
`:7212` — stay status-owned. Clipping candidate glyphs at
`document_text_bottom` would clip the dropdown against a boundary it
does not sit above.

**Document-owned — must move when a band is installed, using
`document_text_bottom`** (12 sites):

| Site | What it is |
| --- | --- |
| `:4566` | `terminal_cell_viewport` — drawable height for the cell grid |
| `:6118` | `completion_anchor_px` — anchor visibility bottom |
| `:6140` | `completion_dropdown_layout` — **document completion placement**; `band_top - (line_top + line_h)` is the space below the anchor line |
| `:6581` | `code_height` |
| `:7174` | Code text clip bottom |
| `:7242` | Math text clip bottom |
| `:7273` | Gutter clip bottom |
| `:7421` | Terminal clip bottom |
| `:8077` | `code_caret_rect_in_clip` — **caret clipping** |
| `:8497` | Minimap drawable height |
| `:8501` | Visible-line estimate |
| `:8561` | `edge_scroll_direction` — **document edge scrolling** |

**Geometry declaration** uses `geometry_capacity_bottom`, and is the
Q#BP15a conversion only.

**Sites that consume no bottom coordinate at all** and must not be
touched: `:3175`, `:3185`, `:6601`, `:6607` size the status text
buffers to `status_band_height` directly. Rev 2 listed them as seam
consumers; they are not.

Three of these classifications are the ones a plausible implementation
gets wrong, and each has a visible symptom: document completion
(`:6140`) anchored to `status_band_top` **overlaps the panel**;
minibuffer candidates (`:7351`) clipped at `document_text_bottom` are
**cut off**; and edge scrolling (`:8561`) left on the old bottom
**auto-scrolls from inside the panel**.

Each call site is classified individually. A blanket rewrite of
`text_area_bottom` to subtract the band would move the status chrome
with the document and is the defect this section exists to prevent.

**The contrast assertion (A2B-4).** "Every document consumer moved" is
only half a test — a uniformly wrong implementation that moves
everything passes it. The criterion must assert **both directions in
one scenario**: installing a panel moves every document-owned consumer
**while the status band stays pixel-identical** at the physical window
bottom. That is the assertion a blanket rewrite fails.

The one-accessor-per-boundary rule still holds within each class: a
second, unrouted derivation of any of the three is the exact shape of
Stage 1's `Layout::compute` two-caller defect, where
`src/overlay_paint.rs` derived its own rect and painted peer cursors at
unfixed rows.

### 5.4 Ordering against folding Stage 3

Settled by review round 1: **bottom-panel Stage 2 first, through the
landed GPU band.** Folding Stage 3 then re-scouts the extracted
painter, the panel projection, clipping, and `fold_projection` behavior
exactly once.

## 6. Coherence impact (per `COHERENCE.md` §20)

- **Journey steps touched: four, on the GPU frontend — steps 7–10**
  (find symbol / find file, terminal, build and test, error
  inspection). Rev 1 said "none directly", which contradicted its own
  next sentence. Today a GPU user who triggers references, project
  search, a terminal, compile, or error inspection gets the Stage 1
  non-side fallback: the output surface steals a document window
  instead of opening a panel. Every one of those steps therefore
  behaves differently on GPU than on TUI, and Stage 2 is what closes
  the divergence.
- **Interaction islands added: none, and this is a reduction.** §6
  grades islands "weak, and growing by one island per modal feature".
  Stage 2 extends one already-adopted policy (`display = "panel"`,
  used by listview, compile, and terminal) to a second frontend rather
  than minting a GPU-only surface. Q#BP14b deliberately reuses the
  existing `SearchPrompt` / `MenuPrompt` / `CompletionPopup` messages
  instead of panel-specific twins.
- **Config registry adoption: inherited, not extended.** Stage 1's
  `window.panel-height` and `window.min-height` already live in the
  registry. Stage 2 adds no new user-facing option; if the band needs
  one, it enters the registry.
- **Background-work attribution: unchanged, and this stage does not
  advance it.** Rev 1 implied Stage 2 helps §9's activity-view gap. It
  does not. A panel gives output a coherent *placement*; it does not
  make terminal PTYs, LSP servers, or workers appear in the
  activity/ownership view §9 describes, and it adds no join key across
  the four disjoint activity planes. The §9 gap is untouched.
- **Section this serves:** `COHERENCE.md` §14, which records the panel
  primitive as landed for Stage 1 and names "Stage 2 (GPU band)
  pending its own framing" as the open item.
- **Which slice pays the coherence debt (rev 5).** The journey claim
  above is Stage 2B-3's alone. 2A, 2B-1, and 2B-2 close **no** journey
  divergence: with `panel_capable = false`, a GPU user still gets the
  Stage 1 non-side fallback on steps 7–10 after all three land. Stated
  explicitly so no slice's PR can claim the arc's coherence benefit
  before the flip earns it — three quarters of this stage is
  preparation, and only the last quarter is the improvement.

## 7. Acceptance

**Parent criteria 37–55 remain authoritative and are not replaced.**
This section maps them to the four slices — 2A, then 2B-1/2B-2/2B-3 —
and adds only refinements. A criterion that spans a slice boundary is
named in each slice it touches, with its half stated.

### 7.1 Stage 2A — classified census routing + painter extraction

No protocol change. Parent criteria that apply in full: **42, 43, 44,
51 (the `LOCAL`-panel inheritance half)**, plus the extraction half of
**52**.

**52 splits across the slices.** 2A has no semantic panel projection
and no `PanelFrame`, so all it can prove is that the extracted painter
honors an explicitly supplied `None` fold map and that the stale
`src/window.rs:562` comment is corrected. The actual contract — *a
semantic panel with `fold_projection = false` never collapses folds and
never calls `fold_map_for_window`* — is production-reachable only once
2B lands the projection and the capability flip. It is therefore
reasserted in 2B (§7.2).

Refinements 2A adds:

- **A2A-1 (replaces rev 1's criterion 1).** Every **Projection** census
  item (#1–#7, #9, #10, #12, #21, #22) resolves through
  `primary_document_window` / `primary_document_buffer`; #8 and #11
  align **and then activate**; **#13, #14, #15, #23 continue to resolve
  the actually focused window**; #16–#19 follow Q#BP14b's routing
  table; #20 keeps its per-session counter with focus choosing the
  eligible terminal. Each class is asserted separately, at the
  outermost user-reachable seam, and falsified by revert. A test that
  only proves "the document is used" would pass with the focus classes
  wrongly rerouted, so the focus-class assertions are the load-bearing
  half.
- **A2A-2 (replaces rev 1's criterion 2).** `src/statusline.rs:644`
  resolves the primary document window, while `:629` and `:675`
  continue to report **actual focus** — pinned by a document provider
  truthfully observing `active = false` while the panel is focused
  (parent 42). The semantic-layout target captures primary document +
  visible side window, invokes each provider exactly once, and
  invalidates the whole evaluation when a callback mutates layout or
  focus.
- **A2A-3 (replaces rev 1's criterion 3).** The painter extraction
  preserves, for grid frontends: the painted **cells**, the **returned
  cursor**, the **focused window's `view_top` mutation** from the
  auto-scroll clamp, and **passive windows' untouched `view_top` and
  scroll state**. Byte-identical cells alone would not catch a clamp
  that silently moved to the wrong window.

### 7.2 Stage 2B — v21 protocol, daemon projection, GPU band

Stage 2B as a whole owns parent criteria **37, 38, 39, 40, 41, 45, 46,
47, 48, 49, 50, 51, 53, 54, 55**, plus re-assertion of **42, 43, 44,
and 52** **through the actual negotiated capability flip** rather than
through a test-only panel-capable semantic view. 52's 2B form is the
production one: a real semantic frontend with `fold_projection = false`
displaying a folded buffer in a panel shows every source line, and the
panel path never reaches `fold_map_for_window`.

Per §0.0 R5-1 those land across three slices. Each slice's own gate run
is the standing suite plus §9's named acceptance suites; **only 2B-3
changes what a user sees.**

#### 7.2.1 Slice 2B-1 — the v21 wire layer

**Authority: `pmacs-protocol`.** The four wire shapes Q#BP9 names, the
version bump, and the shared cell-grid validator. No producer, no
consumer, no capability change.

- **37, in full.** `PanelFrame` round-trips including `panel_epoch` and
  `geometry_epoch`, with independent byte pins on the previous final
  `InstanceMessage::InitialTargetResult` and
  `FrontendEvent::TerminalPointer` variants. **Both pins must be
  falsified by revert**, not merely observed passing: a byte pin that
  never saw the shift it exists to catch pins nothing.
- **39, the wire half only.** Shared cell/topology/glyph/area
  validation; an area-bounded panel wider than 512 columns is accepted
  while a terminal frame retains its 512-column PTY cap; the maximum
  legal panel encoding stays below the transport limit. **The ratchet's
  fixture must be shown to spend the whole aggregate glyph budget** —
  otherwise it measures something smaller than the worst case and the
  bound it proves is not the bound that matters. The worst case is
  `1 × MAX_PANEL_VISIBLE_CELLS`, a legal panel geometry no terminal can
  express, so the terminal's own ratchet has never covered it.
  **39's receiver half — atomic rejection with retention of the
  previous valid frame, and a duplicate doing no work — is 2B-2.**
- **The version ladder moves with the bump.** `PROTOCOL_VERSION`
  becomes 21, `SUPPORTED_PROTOCOL_VERSIONS` accepts `6..=21` and
  rejects 22, and any test whose *name* encodes the old number is
  renamed. A ladder pin that passes across a bump was not pinning the
  version. **`ADVERTISED_PROTOCOL_VERSION` remains 20 in 2B-1 and
  2B-2** because the unsolicited `Hello` precedes any client version
  signal. A real daemon must remain attachable by a client whose
  supported range ends at 20.
- **Shared bounds are aliased, not duplicated.** Every constant the
  terminal screen and the panel validator both enforce is one
  definition with the other as an alias, so truncation and validation
  cannot drift apart.
- **Not in this slice:** the daemon arm that drops panel events from a
  grid session is exhaustiveness bookkeeping the bump forces, not
  projection. It asserts only that a grid session's panel declaration
  is dropped rather than trusted.

#### 7.2.2 Slice 2B-2 — the daemon panel projection and epoch machine

**Authority: `src/daemon.rs`.** Produces `PanelFrame`; derives the
grid; owns stale-event rejection. Exercised through a **test-only**
panel-capable semantic view — `panel_capable` stays `false` in
production negotiation until 2B-3.

- **38** (open → replace buffer → hidden by a tiny frame → reappear →
  close, with authoritative `Absent` and a new epoch on
  replacement/reappearance), **40** (first open at a non-80×24 frame
  stays absent until real `FrontendCellGeometry` arrives, never
  consulting the 24×80 attach placeholder), **49**, **50**, **51**,
  **53**.
- **39's receiver half**, per §7.2.1.
- **41, the daemon half:** the daemon alone derives the grid; an older
  retained frame neither paints nor accepts input after a new
  `geometry_epoch` until a matching `Present` arrives; row-clamping
  preserves the stored request; zero, non-finite, and non-positive
  metric inputs fail closed to zero usable geometry. *The pixel→cell
  formula and its call sites are 2B-3.*
- **42, 43, 44, 45, 52** in their projection form, through the
  test-only panel-capable view. Their production re-assertion through
  the real flip is 2B-3.
- **A2B-1.** The epoch state machine of §3.1 is pinned row by row,
  including the lower-epoch-identical-data rejection and the
  same-epoch-different-total rejection, and each row's
  `Advanced`/`Duplicate`/`Rejected` result is asserted — a `Duplicate`
  performs no reconciliation and a `Rejected` mutates nothing. Epoch
  `0` is rejected on the wire. **Exhaustion is pinned on both sides**:
  grid exhaustion clears `frame_geometry` to unknown and the panel
  hides (a subsequent real resize must not paint a stale-geometry
  panel), and a frontend that exhausts latches — a retained `Present`
  whose epoch still matches cannot make the band reappear, and only a
  fresh session clears the latch. **A2B-1's grid-exhaustion half is
  2B-2; its frontend-latch half needs a real frontend and is 2B-3.**
  Both halves are named here so neither is lost at the seam.

#### 7.2.3 Slice 2B-3 — the GPU band and the capability flip

**Authority: `pmacs-gpu`, plus the compatibility-preserving negotiation
activation.** This is the only slice a user can observe, and the only
one that closes the journey divergence in §6. It must not advertise
v21 in the server-first `Hello` until an existing v20 client can still
attach.

- **46** (band + divider shrink the document text area by exactly their
  pixel height; carets, hits, and scroll geometry respect the reduced
  area), **47** (divider drag, `window.min-height`, `RowResize` hover,
  and the stalled-writer tail-coalescing), **48** (`PanelPointer`
  driving selection, terminal mouse reporting, and click-to-focus
  without disturbing the document mirror), **54** (the
  `--headless-probe` run: one real daemon, real PTY, real wgpu, through
  a panel-hosted terminal — see rev 7 R7-4 for the exact fixture),
  **55**.
  **48 is the whole gesture set, and a partial port of it is invisible**
  (rev 7): `Move` alone satisfies nothing. A left press must ARM the
  gesture so motion becomes `Drag(Left)`; a release outside the band
  must still end the gesture the press began, at the last reported cell,
  or the daemon holds a button down forever; right-click and wheel must
  consult the band before the document, or they are applied to the
  surface underneath. Every handler resolves the band through **one**
  classifier, because four handlers each deciding for themselves is how
  three of them came to skip the question. A **passive** panel's caret is
  not painted: the producer ships `cursor` for it too, so `focused` is
  what decides. Planned underlines must actually render — straight forms
  on the quad batch, curly on the squiggle pipeline, as in the terminal
  path.
- **41, the GPU half:** the pixel→cell conversion pinned at fractional
  widths and heights, and geometry refresh on window resize, font
  change, and scale change.
- **42, 43, 44, 45, 52 re-asserted through the production flip**, not
  the test-only view. This is the point of the re-assertion: a
  test-only panel-capable view can be constructed wrongly and agree
  with itself, so the production negotiation path must carry the same
  assertions.
- **A2B-1's frontend-latch half**, per §7.2.2.
- **A2B-2.** A font or scale change that leaves `CellSize` **identical**
  still produces a new `geometry_epoch`, and the older `PanelFrame`
  neither paints nor hit-tests until a matching `Present` arrives. This
  is the case daemon value dedup cannot see and is why option 1 was
  chosen.
- **A2B-3.** Panel cells are derived from the **stable normal-face
  probe**, not `State::mono_advance`'s document-glyph fallback: two GPU
  frontends with identical metrics and different documents derive
  identical `total.cols`, and a probe returning `None` declares zero
  usable geometry rather than falling back to a document sample.
  **All three consumers are asserted, not just the declaration** (rev 7
  R7-1): the declared total, the pixel a run is painted at, and the cell
  a click hit-tests to must all follow the same advance, and the fixture
  must make the two candidate advances differ so each assertion
  discriminates. The band's rect is asserted at **x = 0 across the full
  surface width**, with the right-edge remainder resolving to no cell
  (R7-2).
- **A2B-4 (contrast assertion).** Installing a panel moves **all twelve
  document-owned consumers** of §5.3 by exactly
  `installed_panel_height + divider_height`, **while all eight
  status-owned sites stay pixel-identical** at the physical window
  bottom. Both halves are asserted in one scenario: a uniformly wrong
  implementation that moves the status band too passes the "everything
  moved" half alone. Three rows carry their own named symptom because
  they are the ones a plausible implementation misclassifies —
  **document completion (`:6140`) must not overlap the band**,
  **minibuffer candidates (`:7351`) must not be clipped by it**, and
  **edge scrolling (`:8561`) must not trigger from inside it**. The
  geometry declaration separately reserves the divider while the panel
  is `Absent`, and the document loses no pixels until a `Present` is
  painted. All three boundaries clamp at zero on a surface shorter than
  its chrome.
- **A2B-5.** `panel_capable` is true only for a v21+ negotiated
  authenticated semantic session; a v20 semantic session is never
  **placed** in a side window, not merely denied the events. The same
  acceptance must attach an actual v20 client to the production daemon
  after v21 activation, so the new path cannot pass by breaking the old
  handshake before placement is evaluated.

## 8. Open items

**None.** Both round-1 open items are decided in §5.3:
`BASE_DIVIDER_HEIGHT = 4.0` at scale 1.0 (scaled, whole strip painted
`ui.divider` and used as the hit rect), and `TEXT_TOP` stays unscaled
with wholesale inset/DPI scaling named as separate work.

One deferral is recorded rather than resolved: **wholesale surface-inset
scaling** (`TEXT_TOP`, `TEXT_LEFT`, and the sibling paddings under
`FontMetrics::scale`) is pre-existing behavior Stage 2 pins rather than
fixes. It belongs to a spacing-system change of its own.

## 9. Slices, branches, and gates

Per review round 1 and §0.0 R5-1: **four serial implementation PRs**,
each a named slice under this framing so one-feature/one-branch/one-PR
holds. **Each slice lands before the next branches** — none are
stacked, and each is cut from `main`.

- **Stage 2A — MERGED as #177** (`main` @ `0a3fcd1`). Classified census
  routing + per-window painter extraction. Branch
  `bottom-panel-stage2a`. No protocol change. The three-boundary GPU
  split is **2B-3**, not 2A: it is only observable once a band can be
  installed.
- **Stage 2B-1 — the v21 wire layer.** Branch `bottom-panel-stage2b`.
  The four wire shapes, the version bump, the shared cell-grid
  validator, and the version-ladder move. The v21 schema is reserved
  while the production daemon continues advertising v20. **No
  producer, no consumer, no capability change** — `panel_capable`
  stays `false`.
- **Stage 2B-2 — the daemon panel projection and epoch machine.** Cut
  from `main` after 2B-1 merges. Produces `PanelFrame` and owns
  stale-event rejection, exercised through a **test-only**
  panel-capable semantic view. Still no production flip.
- **Stage 2B-3 — the GPU band and the negotiated flip.** Cut from
  `main` after 2B-2 merges. The three-boundary text-area split, the
  divider, pointer routing, the compatibility-preserving v21
  activation, and `panel_capable = true` for a v21+ negotiated
  authenticated semantic session. **This is the slice that changes
  what a user sees**, and it repeats 2A's and 2B-2's relevant
  assertions through the real capability flip.

**Each slice runs the full gate set below, not a subset of it.** A
slice that touches only `pmacs-protocol` still runs the GPU and vterm
suites: the shared validator and the wire enums are exactly the kind of
change whose breakage surfaces in a consumer rather than at its own
definition.

Gates for each slice: the standing suite from `CLAUDE.md`, plus the **touched
acceptance suites named explicitly** — the standing rule is to run the
suites a change touches, and "standing suite" does not name them:

- `bottom_panel_stage1_acceptance` — the substrate all four Stage 2
  slices build on.
- `bottom_panel_stage2a_acceptance` — Stage 2A's classified census and
  painter extraction.
- `bottom_panel_stage2b_protocol_acceptance` — Stage 2B-1's v21 schema,
  server-first v20 compatibility, byte pins, and shared validation.
- `bottom_panel_stage2b_daemon_acceptance` — the exact suite name
  reserved for Stage 2B-2's projection and epoch machine.
- `bottom_panel_stage2b_gpu_acceptance` — the exact suite name reserved
  for Stage 2B-3's band, compatible activation, and capability flip.
- `statusline_segments_acceptance` — the fan-out target change (§3.3).
- `m11_5_semantic_acceptance` — the semantic census (§3.2).
- `gpu_initial_target_acceptance` — parent criterion 55.
- `gpu_font_acceptance` — font/scale geometry refresh (§5.3), including
  the normal-face probe and the unscaled-`TEXT_TOP` decision.
- The three vterm suites — the panel hosts terminals.
- Folding Stage 2's 48 — shared projection.
- `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`.

Protocol round-trip and byte-pin tests ride 2B. Parent criterion 54's
`--headless-probe` run — one real daemon, real PTY, real wgpu, through a
panel-hosted terminal — is a 2B gate.
