summaryrefslogtreecommitdiff
path: root/docs/render-pipeline.md
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 09:21:26 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commitcc5323772d5acd07376c561e8e08eaf365b7d22c (patch)
treea65116ac07901e778e158866ded38213c0fb4b2d /docs/render-pipeline.md
parenta9de2b51ab76a43250a4d6c41a0ddd54e6970049 (diff)
downloadpardes-cc5323772d5acd07376c561e8e08eaf365b7d22c.tar.gz
pardes-cc5323772d5acd07376c561e8e08eaf365b7d22c.zip
docs: the render pipeline design, with the user's decisions
Designed by two agents (a designer and an adversarial reviewer) and not yet implemented; implementation starts in stages, each its own change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'docs/render-pipeline.md')
-rw-r--r--docs/render-pipeline.md875
1 files changed, 875 insertions, 0 deletions
diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md
new file mode 100644
index 00000000..2a679b8d
--- /dev/null
+++ b/docs/render-pipeline.md
@@ -0,0 +1,875 @@
+# pardes render pipeline: one frame, one clock, one order
+
+Status: DESIGN, FINAL (draft 2 plus the adversary's two convergence fixes; converged with the adversary; the adversary's 17 objections to draft 1 are folded in, each marked "draft-2 fix" where it changed the design). Nothing here is implemented. Line numbers are
+the working copy of 2026-09-28 (`losuylsy`, mid tag→Text refactor; they drift).
+
+Goal, in the user's words: an algorithm for the ordering of rendering, how each
+part is selected and joined, the shader passes after that, and one animation
+model. Consumers: the lapis theme (shader effects on specific parts), multi-line
+tags, the full-screen post pass (now Shadertoy-compatible), images/PDF, and
+animations. Added requirements: a game-quality GUI effects catalogue, a separate
+terminal effects track (audit + new cell-native effects), and a motion / colour /
+performance / "feel" discipline.
+
+---------------------------------------------------------------------------------
+
+## 1. Today's frame, end to end
+
+### 1.1 Who drives a frame
+
+`Pardes.pump` (pardes.zig:4607) is the only loop body every shell runs:
+`wait_input(timeout = animationActive ? 16 : 0)` → queued events → `update` →
+effects → `poll_frame` → **skip unless `needs_frame or animationActive()`** →
+`frame_arena.reset` → `render` → `present` → `needs_frame = present_skipped` →
+`post_present`.
+
+Ticks (`Event.tick`) are produced by six different clocks:
+
+| shell | who ticks | cadence |
+|---|---|---|
+| tty | `tickWatch` thread: nanosleep 16 ms **after** the wait asked, then `postEvent(.tick)` (tty.zig:1513) | 16 ms + frame time: drifts slow, worse over ssh |
+| gui | `AnimationClock.due` in `postPresent` re-arms `now+16ms` (gui.zig:203, 221, 4095) | a tick only once ≥16 ms have passed since the re-arm: at 144 Hz every 3rd frame (20.8 ms) → animations ~25% slow; at 120 Hz every 2nd; a slow frame slows them further |
+| gui grid mode | every `poll_frame` while active (gui.zig ~4114) | as fast as the loop |
+| web | JS rAF fixed-step bank, capped (app.mjs:566) | correct average rate, always ticking |
+| macOS | `spendTickTime` bank (macos.zig:868, 1549) | correct average rate |
+| detached server | after a timed `poll` (server.zig:816, 828) | ≥16 ms |
+
+So the same animation runs at different speeds per shell. Everything counts
+16 ms frames (`layout.Animation.frame_ms`, layout.zig:2041).
+
+Acknowledgement is also per shell: tty `postPresent` acks the tracks it drew
+(tty.zig:1187), GUI acks then ticks (`finishPresentedAnimationFrame`), grid mode
+acks `&.{}`, macOS acks via `pardes_frame_presented`, **web never acks**.
+
+### 1.2 Core render (`Pardes.render`, pardes.zig:5950), in code order
+
+1. `File.refreshHighlights`; size the grid; reset cursor/pointer shape, **clear
+ every cell's hover bit** (5979 — stale state that exists only because paint
+ passes do not own their output), zero layers/images/tracks.
+2. Fill the whole screen with `chrome.border` (5991).
+3. Per pane: `collectNotices` → `renderPane` (6580): `clearRect` + page fill
+ ("two full passes over every cell in the pane", 6596) → grip → `paintPaneTag`
+ → PDF / image / `renderBody` (+scrollbar) → `renderBodyLayer` (body_layer.zig:243)
+ which **renders the whole body again** into a temporary Surface by
+ `std.mem.swap(Surface, &p.surface, &temporary)` (body_layer.zig:266-272).
+4. Notice chips painted on the grid (6000-6065).
+5. Workspace tag, column tags, their hover word and header selection/caret on
+ the grid (6068-6155).
+6. `renderTagLayers` (6396): **paints every pane tag a second time** into a
+ temp Surface via the same swap (6414-6419); builds notice layers (re-deriving
+ chip geometry, text, caret the grid pass already computed) and column /
+ workspace layers (`renderHeaderLayer` 6520, which re-does hover and header
+ selection).
+7. Drag overlays (`overlayDash`, glyph-only rewrites, 6158); the column-move
+ rail shrinks layer viewports by one cell so the rail stays visible.
+8. Debug box (6244).
+9. `presentation.submit` (6301): tracks, previous grid, per-cell diffs.
+10. `composeAsciiTransitions` (6305): character effects composed in the core
+ into an arena copy of the cells.
+
+Implicit orderings that matter and are written nowhere: body layers before tag
+layers (notice chip over a context band); images after cells, which is why an
+image pane "gives up the rows" under its notices (6629) instead of having z;
+tag layer array index order (panes, columns, workspace, notices) is paint order.
+
+### 1.3 Surface: what the shells get (surface.zig)
+
+`cells` (the canonical grid), `cursor`, `pointer_shape`, `images`
+(`ImagePlace`, with `patch` rows), `body_layers[16]` (compact context rows +
+body rows), `tag_layers[MAX_TAG_LAYERS]` (one row each; the tag refactor just
+added `line` — one layer per tag line), `panel_tracks`, `previous_*` + `cell_diffs`
+while a transition needs them, `mark_hover` (a flag that makes `Surface.at`
+set `cell.hover`, 378-385 — a side channel for macOS glass).
+
+### 1.4 Shells
+
+- **tty** (tty.zig:1100): `panel_compositor.compose` rewrites cells for
+ slide/zoom/dissolve/vertical → `paintCells` (win.clear + every cell) → kitty
+ images (hidden while geometry moves) → cursor → `vx.render` (vaxis diffs
+ against its last screen, wraps in sync 2026). Layers are ignored.
+- **gui** (`renderFrame`, gui.zig:5035): one render pass. `makePaintPlan`
+ (2048) = one batch per active track + static batch. Per batch: grid cells not
+ covered by a layer (`coverLayers` 5656) → body layers → tag layers → previous
+ layers/cells for transitions → that batch's images. Then the overlay
+ (`buildOverlay` 7258: pet, topbar rule, column rule, bottom fill, pane chrome
+ 7468, grips 7543, bar cursors, touch). Then optional crt pass scene_tex →
+ swapchain (5439). Cell = one instance drawing bg + glyph (ui.frag.glsl).
+- **web**: DOM spans for the grid + absolutely positioned divs for tag and body
+ layers. No images, tracks, scene; never acks.
+- **detached** (wire.zig, `version = 7`): cells + body/tag layers, delta coded.
+ A GUI attached client has `core == null` → no pane chrome (appendPaneChrome
+ reads `core.rects`): a per-shell divergence caused by the shell reading core.
+- **macOS** (out of scope): its own copies of tag/body layer ABIs, its own
+ tick bank, Metal crt.
+
+### 1.5 Duplicated logic, smells, bugs
+
+1. Paint twice: pane tags (grid + layer), bodies with context rows (grid + layer),
+ notices (grid + layer, text/geometry/caret recomputed), workspace/column tags
+ and their hover/selection/caret (grid + layer). The two copies already
+ disagree: a notice layer highlights the hovered word, the grid chip does not;
+ the column-tag grid hover is suppressed during `column_move`, the layer's is not.
+2. `std.mem.swap(Surface, &p.surface, …)` as the way to "paint into something
+ else": every paint function writes `p.surface` directly.
+3. Geometry recomputed everywhere: `tag_y = if tag_bottom …` appears in
+ renderPane, paintPaneTag, renderBody, renderTagLayers, appendPaneChrome,
+ taglineBaseRgb; `bodyTop`; BOX_H == 1 assumed throughout (multi-line tags
+ break all of these).
+4. The GUI infers semantics from painted cells: scrollbar thumb from
+ `cellBackgroundIs(scroll_thumb)` (7468ff), topbar rule from "row 1 has a
+ tagline cell" (5509), bottom band (5519), grips from tagline cells
+ (`paneGripCell` 5872), focus tint recomputed from `core.active`
+ (`taglineBaseRgb` 5609). Any theme where two roles share a colour breaks it.
+5. Six clocks (1.1), four ack sites, and a web that never acks.
+6. `animationActive` (5884) is true while crt/ripple/glitch are on → the whole
+ core render (double paints included) and `capturePrevious` run every 16 ms
+ for a time-only uniform.
+7. `acknowledge(&.{})` → `capturePrevious` memcpys all cells and all layer
+ cells on **every idle presented frame, even with `panel_transition = .off`**
+ (layout.zig:283-290).
+8. A lingering message (`message_linger_ms`) keeps `needs_frame` true every
+ tick for its whole linger (Messages.zig:238-250): seconds of full 60 Hz
+ renders that change nothing. Look-hover delay likewise ticks every frame.
+9. `Animation.Transition.retarget` (layout.zig:2064) restarts from the displayed
+ value with step 0: position continuous, velocity snaps to zero.
+10. Colour fades mix sRGB bytes (`interpolateRgb` layout.zig:2111,
+ `Messages.blendRgb`, `colors.mix`): muddy mid-fades.
+11. Shell-side animations each have their own time: crt uniform
+ (`SDL_GetTicksNS`), pet (`SDL_GetTicks`), smooth scroll lag (per poll),
+ touch flash (per rendered frame), macOS coasting.
+12. `mark_hover` hack and the hover-bit reset (1.2 step 1).
+
+---------------------------------------------------------------------------------
+
+## 2. Principles
+
+1. **Core decides WHAT and WHERE; shells decide HOW.** The core emits an ordered
+ region list with geometry, z, and resolved styles. No shell reads
+ `core.rects`, `core.active`, settings or theme to decide what something is.
+2. **Paint once.** Every part is painted exactly once, into its own target; the
+ grid is produced by joining those targets.
+3. **One clock.** Time is the shell's monotonic `now_ns`, passed in; the core
+ never reads a clock. Animations are functions of `now - start` (tweens) or
+ closed-form springs, never frame counters.
+4. **A fixed, named order**, written as straight-line code in one function per
+ side. No pass objects, graphs or vtables.
+5. **Zero cost when off; zero work when idle.** A disabled effect emits nothing.
+ Idle means the earliest deadline is "never".
+6. **Decorations never change cell geometry.** Grid, hit testing, 9P cells and
+ snapshot goldens are unaffected by any GUI effect.
+
+---------------------------------------------------------------------------------
+
+## 3. The frame
+
+### 3.1 Loop (pump, all shells)
+
+```
+pump(h, now_ns):
+ wait_input(timeout = min(core.nextWake(now), shell's own next wake))
+ drain events → update ; drain effects → perform
+ core.advance(now) // settles animations whose time passed; sets
+ // needs_frame if something core-visible moved
+ poll_frame
+ if needs_frame or core.running(now):
+ render(now) → present → if present said "shown": core.acknowledge(tracks)
+ else if shell has a presentation-only animation:
+ shell redraws at level A (post chain only) or B (decor only), §5.5;
+ no core render, no cell-instance rebuild
+```
+
+`animationActive: bool` becomes `nextWake(now) ?u64` (null = idle, 0 = run
+every frame, >0 = sleep until). `acknowledge` moves into `pump` (present returns
+whether it showed the frame); the four shell ack sites and GUI's
+`finishPresentedAnimationFrame` go. `capturePrevious` runs only when
+`panel_transition != .off`.
+
+### 3.2 Core `render` (moves to `src/draw.zig`, `pub const render = draw.render`)
+
+Straight-line, in this order:
+
+```
+render(p, arena, now):
+ 0. BEGIN size grid; reset regions, layers, images, cursor, tracks
+ 1. PLACE compute every part's rect ONCE and append a Region, in z order:
+ page; per pane: body, tag (h = tag lines), grip, rail(+thumb),
+ notices; per column: tag + grip; workspace tag; drag overlays;
+ debug box; cursor
+ 2. PAINT for each region: paint its cells once into its target — its
+ layer (tags, notices, bodies with compact rows) or the grid
+ 3. JOIN copy each layer's grid-visible cells into the grid at its
+ viewport, in region order → the canonical grid (tty/9P/wire)
+ 4. GRID-ONLY glyph-only overlays (drag dashes) and the debug box on the grid
+ 5. PRESENT presentation.submit (tracks, diffs); region tier from role
+ and track phase (§3.4)
+ 6. STYLE resolve region styles: lift, dim (focus animation), the
+ theme's decor per region kind, style_alpha, per-pane view origin
+```
+
+Paint functions take the target `s: *Surface` instead of writing `p.surface`
+(mechanical signature change through body_layer, File, Terminal, Image, Pdf);
+the swap hack and the second tag/body/notice paint are deleted. Where the
+old grid and layer copies disagree, **the grid's behaviour wins** so goldens
+stay byte-identical. Every disagreement is listed in the change and decided
+on its merits later (e.g. the column_move hover suppression is right for both;
+notice-word hover could go either way). JOIN traps: (a) a wide grapheme at the
+viewport's right edge. `print` drops a width-2 glyph that does not fit
+(`col + 1 >= end`), but the layer has columns beyond it, so the copy must
+re-clip at the edge and never leave a head without its spacer. (b) The body
+layer's first `r.h − tag_rows` rows must equal today's grid rows with
+`tag_bottom` both on and off (`first_row`, body_layer.zig ~275).
+
+### 3.3 Which parts render, and from what
+
+- A region is emitted iff its rect intersects the screen and its owner is
+ visible (collapsed pane → tag region only).
+- Pixel shells draw a region **from its layer if it has one, else from the grid
+ cells in its rect**. The page region draws only grid cells no other region
+ covers (today's `coverLayers`, generalised to regions).
+- Cell shells (tty, detached tty) draw the grid; layers exist only for pixel
+ shells.
+
+### 3.4 Composition order (GUI): fixed tiers by ROLE
+
+A region's tier comes from its **role** and is fixed for the frame. Animated
+elevation (`Region.lift`, 0..1) only drives shadow offset, σ and strength, so
+nothing changes draw order partway through a lift (draft-2 fix: an animated
+`z` whose floor is the tier makes the draw order pop mid-animation).
+
+| tier | role | overlap inside the tier? |
+|---|---|---|
+| 0 | page, static panes, their chrome (rails, grip marks, rings, rules) | no |
+| 1 | the active pane (only when focus lift is on) | no |
+| 2 | panes moving/opening in a transition | **yes**: split per track, in `paintOrder` |
+| 3 | closing tombstones (frozen previous cells) | **yes**: split per track |
+| 4 | floating: notice chips, debug box, drag preview | no |
+| 5 | true overlays: cursor, touch/pet (debug) | no |
+
+A pane's own chrome (rail, grip marks, rings, rules) is decor of the pane's
+tier and carries its track transform, so it moves with the pane. Today it is
+hidden during transitions (`transient_on`, gui.zig ~7299) because it doesn't
+move. Only real overlays live in tier 5.
+
+Straight-line GUI draw, one render pass:
+
+```
+for tier 0..5:
+ for each group in tier // one group per tier, except tiers 2 and 3:
+ // one group per track, in paintOrder
+ decor-under fills, patterns, rings under content [decor]
+ cells bg + glyph; text-shadow regions in 3 phases [ui]
+ images the group's images [image]
+ cast shadows this group's shadows, darken-only, each with
+ its caster's rect as an exclude rect [decor]
+ decor-over rings over edges, rails, rules, grip marks [decor]
+post chain (§6) → present
+```
+
+Why cast shadows come AFTER cells (draft-2 fix): static panes share tier 0. If
+the shadow were drawn before the tier's cells, the neighbour's opaque cells
+would paint over a shadow cast onto its gutter or tag row, which is exactly the
+lapis case. Each shadow instance discards inside its caster's rect, so it only
+darkens what lies beside or under it. An elevated tier's shadow onto lower
+tiers works the same way.
+
+Draw calls: ≤ 5 per group. Tiers 0, 1, 4 and 5 are one group each, so ~20 draws.
+Tiers 2 and 3 add ≤ 16 groups, and only while a transition runs. Empty draws are
+skipped. The per-track batching that exists today (PaintPlan) survives only in
+tiers 2 and 3, as groups. The vertical effect's scissor becomes a per-instance
+clip rect. A notice chip (tier 4) ends up above an image, so the image pane can
+stop "giving up the rows" (its own visual-change step).
+
+---------------------------------------------------------------------------------
+
+## 4. Data the core hands the shells
+
+Additions to `Surface` (all POD, fixed-size):
+
+```zig
+pub const Region = struct {
+ kind: enum(u8) { page, body, tag, grip, rail, column_tag, workspace_tag,
+ notice, overlay, debug },
+ pane: u8 = none, column: u8 = none, serial: u32 = 0,
+ rect: Rect, // grid cells: the hit-test geometry
+ tier: u8 = 0, // fixed by role (§3.4)
+ lift: f32 = 0, // animated elevation 0..1: shadow only
+ dim: f32 = 0, // 0..1 pull toward the page (unfocused)
+ layer: u16 = none, // layer index, or draw the grid cells in rect
+ style: u8 = 0, // index into Surface.styles (0 = plain)
+ style_alpha: f32 = 1, // decor weight; theme crossfade (below)
+ active: bool = false,
+ thumb: struct { y: u16, h: u16 } = .{}, // rail only
+};
+regions: [MAX_REGIONS]Region, nregions: u16,
+styles: [8]Style, // resolved per frame from theme + settings
+view: [MAX_PANES]struct { serial: u32, top_line: i32, wrap_at: i32 }, // ABSOLUTE per-pane view origin (§5.4)
+```
+
+`Style` holds a theme's decor for one region kind. Hard-edged geometry is in
+**logical pixels** and snapped to whole device pixels by the shell (logical px ×
+display scale, rounded): ring widths, pattern periods, hard-shadow offsets.
+Lapis is pixel art (2/3/1 px rings, 6 px checker, 5-6 px hard shadow). In
+fractional cell units those would blur and change with font size (draft-2 fix).
+Fields: `fill`, `ring[3] {px, rgba}`, `pattern {none|checker|stripes|dots, px,
+duty, a, b, see_through}`, `hard_shadow {dx_px, dy_px, rgba}`, `text_shadow
+{dx_px, dy_px, rgba}`. Soft elevation shadows are not per style. They come from
+`lift` and one global `shadow {rgba, σ_px, offset_px}` per theme.
+
+`see_through`: a region style with an under-pattern (the lapis dot grid under
+pane bodies) marks page-coloured cell backgrounds as clear, using the existing
+ui.frag clear-bg flag (0x40000000), so the pattern shows between glyphs. In a
+tiled layout the page region is almost never visible, so the pattern has to sit
+under the bodies.
+
+Theme crossfade: switching into or out of a decorated theme fades
+`style_alpha` from 0→1 or 1→0 over the same Tween as the colours. Decor never
+snaps while colours fade. Schema: `decor` is an optional field on `Theme`. The
+comptime `fold` and the ZON ThemeFile parser (colors.zig:266) both walk the
+same struct, so theme files get decor with no separate schema. Lapis ships
+compiled in first.
+
+Layers: once the tag refactor lands, `TagLayer` and `BodyLayer` merge into one
+`Layer { kind, id, serial, viewport, cols, rows, compact_rows, cells, cursor
+{x,y,bar}, bg, slide, fade, separators }`. A tag is all compact rows. A body is
+`context_rows` compact rows followed by body rows. A notice is one compact row.
+There is one `rowTop/rowHeight/hitAt`. Multi-line tags are `rows > 1`, which
+replaces the refactor's one-layer-per-line `line` field. This is the only part
+that touches the other agent's files, and it waits for them.
+
+Time: `Surface.now_ns`, the core's time for this frame.
+
+Wire: regions and styles go over the detached wire (`version` 7 → 8). The
+attached GUI then draws chrome from them, which fixes the core==null
+divergence. Web ignores the new data at first. Its lapis could later map styles
+onto CSS box-shadow, borders and gradients, a natural fit since the reference
+IS CSS.
+
+Deleted from the shell once regions exist: `taglineBaseRgb`,
+`topbarPaneBorderHeight`, `bottomTaglinePresent`, the scrollbar inference in
+`appendPaneChrome`, `cellBackgroundIs`, `paneGripCell`'s scan, and `mark_hover`.
+The hover affordance becomes an `overlay` region.
+
+---------------------------------------------------------------------------------
+
+## 5. Region effects on the GPU
+
+### 5.1 One decor pipeline, instanced
+
+New `shaders/decor.{vert,frag}.glsl`. Each decoration is one instance:
+`{ rect_px, clip_px, exclude_px, kind, params vec4, color0..2 (premultiplied),
+transition header (the same fields as CellInstance) }`.
+
+- **Hard-edged kinds, no AA, snapped edges**: `solid`, `ring` (up to 3 inset
+ rings), `checker`, `stripes`, `dots`, `hard_shadow`. Rules, rails and grip
+ marks become `solid` decor emitted from regions. Because they are snapped and
+ un-anti-aliased, stage 8's "PPM goldens byte-identical" is achievable.
+- **Soft kinds, fwidth AA, dithered**: `soft_shadow` (a rectangle convolved
+ with a Gaussian in erf closed form, as Wallace and GPUI do: one quad grown by
+ 3σ, no blur pass), `inner_shadow`, `glow`, and `cursor` (rounded corners).
+ Wide gradients get ±0.5/255 interleaved-gradient-noise dither.
+
+The triangle overlay pipeline stays only for the pet and the touch debug view.
+
+### 5.2 Shadows and gamma
+
+A pane's right and down shadow lands on its neighbour's gutter or tag row
+(chrome), because pane rects tile. With `tag_bottom` it lands on the first body
+row instead, which is simply what a shadow does. §3.4 guarantees it is drawn
+over the neighbour and never over the caster.
+
+Gamma decision (draft-2 fix, one rule instead of two). Decor blends on the
+UNORM target, so blending happens in sRGB space, as CSS does.
+- Dark shadows: alpha is pre-warped so the result matches linear light,
+ `a' = 1 − (1 − a)^(1/2.2)`. This is exact for black.
+- Coloured decor (glows, selection halo, tinted shadows) is capped at ≤ 25%
+ alpha, where sRGB-space falloff is only slightly dark. That is documented, and
+ it is accepted by the feel review, not by maths.
+- Opaque decor (lapis hard shadows, rings) has no gamma issue.
+- Measured experiment in the fx set: render the scene into RGBA16F linear
+ (ui.frag writes a linearised `mix(bg,fg,a)`, so glyphs look identical) and add
+ one encode pass that also produces the sRGB iChannel0. It is adopted only if
+ its cost at 4K on the iGPU fits §8.3.
+
+### 5.3 Text shadow
+
+Only for regions whose style has `text_shadow` (lapis titles). The region's
+cells are emitted in three phases in its group: all bg instances (glyph = the
+space slot), then shadow glyph instances (offset, shadow colour, clear bg), then
+glyph instances (clear bg). Every other region keeps its single bg+glyph
+instance. Within a group, bodies come before tags, so a tag's shadow can hang
+over the body's top edge.
+
+### 5.4 Cursor (shell-owned presentation)
+
+The cursor is decor in tier 5, not a reversed cell. It is a quad whose 4 corners
+follow the target on critically damped springs. The leading corners are stiffer
+than the trailing ones, which gives a smear along the motion that collapses at
+rest.
+- **Distance rule** (draft-2 fix): a move of ≤ 1 cell, or any move in insert
+ mode, snaps or settles in ≤ 40 ms. Only jumps glide (90-150 ms), as neovide
+ does. At key-repeat rate (~33 ms per cell) a 120 ms spring would trail 3-4
+ cells behind and feel sluggish.
+- **Scroll-aware**: the core exposes each pane's ABSOLUTE view origin
+ (`view[pane]`: serial, first line, wrap offset). The shell diffs it against
+ the origin it last drew and shifts its spring state by the difference in
+ pixels, so the cursor does not glide against text that jumped. It is absolute,
+ not a per-frame delta, because frames get dropped: the detached server skips
+ clients with pending output (server.zig ~712), and a GUI can skip a present.
+ G6 smooth scroll uses the same origin.
+- **Legible during the glide**: glyphs under the quad are re-emitted in the
+ cursor-text colour, clipped to the quad (per-instance clip), as neovide does.
+- **Geometry**: the pixel rect comes from the layer the cursor sits in (body
+ layer compact rows, or tag layer pitch plus band offset), not from `cols × cell_w`.
+- **Blink**: default OFF, as pardes never blinked. When on, it is a square wave
+ with an ~80 ms ease at each edge, so it redraws only at the edges, held solid
+ while typing and for 500 ms after, and it stops after 10 s idle.
+
+### 5.5 Presentation-only redraw levels (draft-2 fix: zero idle cost must be real)
+
+| level | trigger | work |
+|---|---|---|
+| A: chain only | iTime or ShaderAnimation, scene unchanged | re-run only the post chain from the retained `scene_tex` |
+| B: decor only | cursor glide, blink edge, hover fade | reuse the uploaded cell vbuf and image draws; rebuild and upload the decor instances AND the cursor-legibility glyph instances (the glyphs under the cursor quad in cursor-text colour, ui pipeline, clipped to the quad); redraw the scene, then the chain |
+| C: core frame | `needs_frame` or a core animation | full render and instance build |
+
+Cell instances are rebuilt only at level C. With a chain active, the cursor
+lives in the scene, so cursor motion is level B, not A (a documented choice:
+iCurrentCursor shaders still get exact uniforms).
+
+### 5.6 tty / web / wire
+
+GUI decor is GUI-only. The tty realises nothing from `styles` beyond the colours
+that are already in cells; its own effects are in §9.2. Web ignores the new
+data. The wire carries regions and styles so an attached GUI draws the same
+frame.
+
+---------------------------------------------------------------------------------
+
+## 6. Post passes: a Shadertoy-compatible chain (GUI)
+
+Mirror ghostty 1.3.2 (`zig-pkg/ghostty-*/src/renderer/shadertoy.zig`,
+`shaders/shadertoy_prefix.glsl`, `generic.zig:2106-2230, ~1692`):
+
+- **Source format**: user file with `void mainImage(out vec4 fragColor, in vec2
+ fragCoord)`, untouched. pardes prepends its OWN prefix with ghostty's exact
+ uniform names, types and std140 order (iResolution, iTime, iTimeDelta,
+ iFrameRate, iFrame, iChannelTime[4], iChannelResolution[4], iMouse, iDate,
+ iSampleRate, iCurrentCursor, iPreviousCursor, iCurrentCursorColor,
+ iPreviousCursorColor, iCurrentCursorStyle, iPreviousCursorStyle,
+ iCursorVisible, iTimeCursorChange, iTimeFocus, iFocus, iPalette[256],
+ iBackgroundColor, iForegroundColor, iCursorColor, iCursorText,
+ iSelectionForegroundColor, iSelectionBackgroundColor; CURSORSTYLE_* defines;
+ `#define texture2D texture`), but SDL GPU bindings: `iChannel0` at set 2
+ binding 0, the block at set 3 binding 0. `main()` calls
+ `mainImage(_fragColor, gl_FragCoord.xy)`. Trap found: ghostty's Zig
+ `Uniforms` (shadertoy.zig:13) orders selection_background before
+ selection_foreground while its GLSL block orders Foreground before Background,
+ so in ghostty the two arrive swapped. pardes fills by the GLSL names (the
+ documented meaning); a unit test checks our Zig mirror's std140 offsets
+ against the prefix (≈4.6 KB block, pushed with SDL_PushGPUFragmentUniformData).
+- **Y is down**, exactly like ghostty on Metal (`custom_shader_y_is_down`):
+ fragCoord, iChannel0 UV and cursor uniforms share one top-left space, no flip.
+ `iCurrentCursor = (left x, BOTTOM-edge y, w, h)` in framebuffer pixels (the
+ "+Y edge" rule community cursor shaders rely on). Directional shaders appear
+ flipped versus Linux ghostty (OpenGL, y-up); documented, not emulated.
+- **Chain**: a list of paths, run in order; ping-pong two UNORM textures
+ (cleared to 0), the last pass writes the swapchain. iChannel0 holds
+ sRGB-encoded values (scene_tex and swapchain are UNORM today — keep it so; if
+ the scene ever moves to an _SRGB/float target, encode before the chain). Empty
+ chain → render straight to the swapchain, no scene texture (today's cost).
+- **Uniform sources** (shell): iTime since the first post frame, iTimeDelta
+ between draws, iFrame per draw (ghostty semantics, shell wall clock); iMouse =
+ real Shadertoy mouse (superset; ghostty leaves it 0); cursor rect/colour/style
+ from the region cursor (5.4 geometry), iTimeCursorChange stamped when rect or
+ colour changes; iFocus/iTimeFocus from window focus events; palette,
+ background, foreground, selection from the theme.
+- **Alpha**: forced to 1 unless WindowOpacity < 100 (then passed through
+ premultiplied). Transparent grounds keep working.
+- **Input mapping**: identity. The bundled CRT has NO geometric distortion
+ (beam-profile scanlines, aperture mask, bloom, vignette), so input stays exact.
+ Barrel exists only in user shaders, with the drift documented: 1.6% is ~30 px,
+ ≈3 cells, at the corners of a 4K screen, where the grips and rails are.
+ `crt.zig` is deleted. If the user insists on barrel in the bundled CRT, keep
+ `crt.zig`'s inverse keyed to "bundled crt active".
+- **Compile**: GLSL → SPIR-V at runtime with glslang (ghostty's `pkg/glslang`,
+ the same vendored package in zig-pkg, GUI build only, behind `-Dshadertoy`).
+ Compiled on load and on file change (file_watch), never on the frame path;
+ the last good pipeline stays on error; the error is a pane message. glslang is
+ C++ with its own allocator: a recorded exception to the "Zig allocator hooks
+ in all C deps" policy.
+- **Animation mode**: `ShaderAnimation off|on|always` (ghostty's
+ `custom-shader-animation`): off = redraw only when content changes; on =
+ continuous while the window is focused; always = continuous. Continuous means
+ the shell redraws its retained Surface (3.1), never a core tick or render.
+- **Existing crt/ripple/glitch** become bundled Shadertoy files in
+ `shaders/post/` selected by the same builtins; `scene_effects` and
+ `crt.frag.glsl`/`crt.zig` are deleted. The bundled CRT is rewritten to
+ quality (6.1); ripple and glitch are dropped unless the feel review keeps a
+ rewritten version.
+- macOS (out of scope) would need spirv-cross → MSL, as ghostty does.
+
+Region filters (e.g. "tint/blur one pane") are NOT built. If one is ever needed:
+one post pass reading a uniform array of ≤16 region rects+kinds, not a pass per
+region.
+
+---------------------------------------------------------------------------------
+
+## 7. Animation model
+
+### 7.1 Time
+
+- The shell's monotonic `now_ns` is the only clock. It enters the core with each
+ `pump` (and a `.tick = now_ns` event for shells without pump, e.g. esp32).
+ Tests inject a fixed clock: a helper steps `now` by 16.67 ms.
+- Two domains, one clock:
+ - **core animations** (change what hit-testing or the grid show, or state):
+ panel geometry, message life, notice slide/fade, look-hover delay, chrome
+ theme crossfade, focus lift/dim. Stored in the core, deterministic in tests.
+ - **presentation-only animations** (never touch core state): cursor glide/
+ trail/blink, shader iTime, smooth-scroll pixel offset, pet, touch flash.
+ Owned by the shell, redraw the retained Surface, never tick the core.
+
+### 7.2 Two primitives, closed form
+
+```zig
+Tween { start_ns, duration_ns, from, to, curve } // value = lerp(from,to, curve(t))
+Spring { start_ns, x0, v0, target, omega } // critically damped:
+ x(t) = target + (c1 + c2 t) e^{-ωt}, c1 = x0-target, c2 = v0 + ω c1
+```
+
+Both are pure functions of `now`: frame-rate independent, free catch-up after a
+sleep, no per-tick `advance` loops. Retarget: a Spring evaluates `(x, v)` at now
+and restarts from them (velocity kept); a Tween retargets from the displayed
+value with an ease-out curve (starts moving at once, no ease-in stall).
+Colours interpolate in OKLab (small CPU function), not sRGB bytes.
+
+Settling (draft-2 fix: a critically damped spring never reaches its target).
+Each quantity has an end rule: position springs end when |x − target| < 0.5
+device px and |v| < 1 px/s, lift/elevation when < 0.002, and then they snap to
+the target and stop. A test checks that `nextWake` returns null after settle.
+A Tween ends at `start + duration`.
+
+### 7.3 Idle and wakes
+
+`nextWake(now)` is ONE straight-line function listing every core animation
+(the same list `advance` settles):
+
+- running tween/spring → 0 (frame now)
+- holding phase (message linger, look-hover delay) → its end time
+- nothing → null
+
+Shells wait `min(core wake, own wake, input)`. A lingering message now costs
+zero frames until it starts to dissolve. `advance(now)` performs the state
+changes at phase ends (message entering→shown→lingering→leaving→removed,
+track done → drop, hover wait → preview) and sets `needs_frame` only when
+something visible changed.
+
+### 7.4 What each animation becomes
+
+| today | becomes |
+|---|---|
+| `ChromeAnimation` 10 steps sRGB (colors.zig:192) | Tween, 200 ms, OKLab, ease-in-out; retarget ease-out |
+| panel `Track.frame` counters (layout.zig:1583) | geometry Spring per track (retarget-safe); content crossfade Tween |
+| `MessageLife.frame` (Messages.zig) | phase + start_ns; motion from Tween curves |
+| look-hover wait frames (look.zig:902) | deadline |
+| GUI `AnimationClock`, tty `tickWatch` cadence, web `animationTicks`, macOS `spendTickTime`, detached tick | deleted; each shell passes `now_ns` and sleeps to `nextWake` |
+| crt time uniform, pet, touch flash, scroll lag | shell-owned, same now_ns source |
+
+---------------------------------------------------------------------------------
+
+## 8. Motion, colour, performance, feel (both tracks)
+
+### 8.1 Motion spec
+
+| class | examples | curve | duration |
+|---|---|---|---|
+| micro-feedback | hover affordance, button/word highlight, selection appear | ease-out (cubic) | 80–120 ms |
+| follows input | cursor, smooth scroll, drag preview, pane geometry under interaction | critically damped spring | settles 90–150 ms (cursor), 180–260 ms (layout) |
+| arriving | notice drop-in, pane open | ease-out (cubic / quint), no overshoot | 150–220 ms |
+| leaving | notice dissolve, pane close | ease-in (cubic), shorter than arriving | 100–160 ms |
+| state change | focus lift/dim, theme crossfade | ease-in-out (smooth) | 150–250 ms |
+| anything > 300 ms | — | must be justified in the feel review | — |
+
+Rules: leaving is faster than arriving; nothing overshoots at text scale
+(a 2 px bounce reads as jitter); interruptions retarget (7.2), never snap or
+restart from zero; **input is never gated**: hit testing uses logical geometry
+(`presentation.pointer` already does), keys act on the logical state
+immediately, animations only lag the picture.
+
+### 8.2 Colour and contrast spec
+
+- Targets (WCAG ratio via `colors.themeContrast`, colors.zig:113): body text vs
+ page ≥ 4.5 (aim 7); secondary ink (comments, line numbers) ≥ 3; tag/chrome text
+ vs its band ≥ 4.5; non-text chrome (rules, grips, scroll thumb) ≥ 3 vs
+ neighbours; selection text vs selection bg ≥ 4.5.
+- Absolute targets apply to the 15 native themes only, matching the existing
+ test (colors.zig ~465), including its deliberate 2.5-4 band for line numbers
+ (lineno is the exception to "secondary ≥ 3"). Imported themes get a RELATIVE
+ rule: with every effect at its maximum (shadow darkening, unfocused dim, glow),
+ no text/ground pair may drop below min(its original contrast, the target).
+ Measured against the composited ground. If an effect would break the rule, its
+ strength is clamped for that theme.
+- Subtlety budget: elevation shadow max 30% darkening at the edge, σ 0.4–0.8
+ cell; unfocused dim ≤ 10% toward the page; glows ≤ 25% alpha; bloom threshold
+ high (only true highlights), strength ≤ 5%.
+- Gamma: effect maths in linear light (shaders convert), shadows via pre-warped
+ alpha (5.2), crossfades in OKLab; glyph coverage untouched (ui.frag stays).
+ Wide gradients dithered.
+- Focus hierarchy by light, not only hue: the active pane is lifted (shadow),
+ unfocused panes recede (dim), the cursor is the brightest thing on screen,
+ chrome is quieter than text.
+
+### 8.3 Performance budget
+
+- Targets: 60 Hz → 16.6 ms, 144 Hz → 6.9 ms end to end. Shares (144 Hz,
+ 200×60 grid, 4K, iGPU): core render ≤ 1.5 ms (only on state change), GUI
+ instance build ≤ 1 ms, scene GPU ≤ 2 ms, decor+text-shadow ≤ 0.5 ms, post
+ chain ≤ 2 ms for two passes (bloom at half res). Measured with the existing
+ tracy zones plus GPU timestamps; numbers are budgets to verify, not facts.
+- Idle: zero ticks, zero renders, zero presents (7.3); blink stops after 10 s.
+- Presentation-only animation: shell redraw only, no core render.
+- Degrade order: post chain to half res → bloom off → soft shadows become hard
+ (σ = 0) → cursor trail off → transitions snap. Hysteresis: step down after 30
+ of the last 60 frames miss the budget; step up only after 10 s with none
+ missed; at most one step per 2 s, reset on theme or window-size change. A step
+ is never taken mid-transition, only at the next idle, and each one is logged
+ (and shown in Debug). Input handling is never deferred for effects.
+
+### 8.4 Feel review (gate before an effect is kept)
+
+Each effect lands behind its own toggle, default off. Before "keep": render a
+frame sequence with the injected clock (GUI capture mode, PPM per frame, at 60
+and 144 Hz; tty via the snapshot harness `snapstyle` per frame) and a live
+screen recording; judge in motion against a reference (the lapis page, ghostty
+cursor trail, neovide smear); record keep / polish / drop with one line of why
+in `docs/effects.md`. Code review never substitutes for this.
+
+---------------------------------------------------------------------------------
+
+## 9. Effects catalogues
+
+Every effect: a toggle (one `Fx` bitset in settings + builtin words, like
+`scene_effects` today), zero instances/passes/uniforms when off.
+
+### 9.1 GUI track
+
+Existing, verdicts: crt → REWRITE as bundled Shadertoy (Lottes-style mask,
+proper scanline beam profile, subtle); ripple → DROP; glitch → DROP (or a
+tasteful rewrite only if the feel review asks); pet → keep as-is, out of scope;
+transitions slide → POLISH (spring + elevation shadow while moving); zoom → DROP;
+dissolve → REPLACE with a linear-light crossfade; vertical → POLISH timing; the
+character effects (ascii…typewriter) → not a GUI effect (terminal track).
+
+| # | effect | needs from pipeline | pri |
+|---|---|---|---|
+| G1 | soft elevation shadows onto lower panes (active lifted, floating chips, dragged pane) | regions + z tiers, decor `soft_shadow` | P1 |
+| G2 | focus transition: lift (spring) + unfocused dim | core focus Tween, `Region.dim`, cell-instance dim | P1 |
+| G3 | cursor: jumps glide on springs, adjacent moves snap (≤40 ms), 4-corner smear with glyphs re-drawn legibly inside it, optional edge-eased blink | layer geometry, per-pane view origin, shell time, decor `cursor`, redraw level B | P1 |
+| G4 | Shadertoy chain + bundled CRT, bloom (half-res dual-Kawase), vignette+grain | post chain, offscreen ping-pong, shell time | P1 |
+| G5 | lapis decor: rings, checker, stripes, hard offset shadows, title text shadow | styles, decor kinds, 3-phase text shadow | P1 (lapis set) |
+| G6 | smooth scroll: pixel offset on a spring, momentum for touchpads | shell-side; replaces scroll_lag stepping | P2 |
+| G7 | panel transitions redone: spring geometry, shadow while moving, linear crossfade | Track as Spring, tiers | P2 |
+| G8 | notice chips: elevation, shadow, ease-out drop, ease-in fade | tier 4, existing slide/fade | P2 |
+| G9 | selection glow: soft halo behind selection runs, 100 ms fade-in | selection runs as regions (overlay kind) | P2 |
+| G10 | theme crossfade in OKLab, 200 ms | Tween | P2 |
+| G11 | look-hover affordance: soft underline glow, 80 ms | hover region | P3 |
+| G12 | pane edge ambient occlusion (inner shadow, 1–2%) | decor `inner_shadow` | P3 |
+| G13 | parallax body pattern (lapis dots under see-through bodies at 0.25× scroll) | `see_through` style, per-pane view origin | P3 |
+| G14 | damage afterglow (changed cells glow briefly) | shell-side diff of instances | P3 |
+
+### 9.2 Terminal track (tty, cell-native; separate, smaller)
+
+Where: presentation-only cell rewrites in the tty shell after the canonical
+grid (`tty/panel_compositor.zig` grows into the tty compositor), never in the
+canonical grid, so 9P/goldens stay clean with effects off. Gated on truecolor
+(vaxis caps / COLORTERM) — static effects may quantise to 256, animated ones
+turn off below truecolor. Safe glyph set by default (░▒▓ ▀▄▌▐ ▖▗▘▝);
+sextants/octants behind a setting. Budget: an animated frame ≤ 8 KB of output
+(≤ 4 KB and 30 Hz when `SSH_CONNECTION` is set); prefer effects whose per-frame
+delta is a moving edge, not a full-pane rewrite; without sync 2026, animated
+effects are off (static ones stay). vaxis has `caps.rgb` but no 2026 cap: the
+tty sends its own `CSI ? 2026 $ p` (DECRQM) at startup, next to the kitty shm
+probe; no answer = assume unsupported. Under tmux, tmux answers for itself (its
+own 2026 handling; old tmux does not answer → animated effects off), which is
+correct, because tmux is the thing that repaints the outer terminal.
+
+Enforcing the budget (draft-2 fix): tty animations are time-based, so dropping
+samples is free and correct. After each `vx.render` the shell counts the bytes
+written. If a frame exceeded the budget, the next sample is deferred by
+`bytes / budget × frame` ms. Slide and vertical therefore get fewer samples over
+ssh; they are not exempt. Effects are designed so each cell changes as few
+times as possible over the whole animation.
+
+Audit of what exists:
+
+| effect | where | verdict | why |
+|---|---|---|---|
+| ascii (byte walk) | core compose | REMOVE | slot-machine noise, rewrites every changed cell every tick |
+| edges | core compose | REMOVE | gimmick, full-pane churn |
+| fall | core compose | REMOVE | gimmick |
+| wave | core compose | REMOVE | gimmick, illegible mid-motion |
+| scramble | core compose | REMOVE | noise every tick, worst byte cost |
+| typewriter | core compose | REMOVE | slow on big panes, row-major reveal reads as lag |
+| curtain | core compose | POLISH → wipe | a directional reveal is good; give it a 2-cell soft truecolor edge |
+| slide | tty compositor | POLISH | ≤150 ms ease-out, pane open/close only; a full-pane rewrite per sample, so the byte budget thins it to few samples over ssh |
+| zoom | tty compositor | REMOVE | nearest-neighbour cell scaling is garbage |
+| dissolve | tty compositor | POLISH → ordered swap | Bayer 4×4 threshold per cell; each cell swaps old→new ONCE at its threshold (no continuous colour lerp), so the total output is one pane's worth spread over the animation |
+| vertical | tty compositor | KEEP (timing polish) | reads as a drawer, cheap |
+| message fade | core (grid colours) | KEEP (OKLab) | |
+| theme crossfade | core | KEEP (OKLab, fewer steps over ssh) | |
+
+Removing the six core-composed effects deletes `composeAsciiTransitions`,
+`charSource`, `AsciiDiff`, `PanelCellDiff.ascii` and their shader/GUI paths
+(user decides).
+
+New, cell-native:
+
+| # | effect | technique | pri |
+|---|---|---|---|
+| T1 | floating shadow for chips/debug box/drag preview | 1 cell right/down darkened in OKLab; blank edge cells get ▗▄/▐ half-cell edges | P1 |
+| T2 | cursor jump trail | on jumps ≥ 3 cells: 3–5 cells along the path, bg ramp cursor→page, 120 ms; tiny delta | P1 |
+| T3 | wipe transition | 2-cell soft leading edge; only the edge changes per frame | P2 |
+| T4 | ordered swap transition | = polished dissolve: Bayer threshold, one swap per cell | P2 |
+| T5 | unfocused dim | fg 15% toward bg, instant (no animation over ssh) | P2 |
+| T6 | active tag gradient | subtle horizontal truecolor ramp on the active tag band | P3 |
+| T7 | scroll thumb flash | rail thumb brightens 300 ms on scroll | P3 |
+
+---------------------------------------------------------------------------------
+
+## 10. Lapis on this pipeline
+
+Colours: a normal theme file (lapis #0f1a4a, deep #0a1030, ink #050a24, gold
+#e8c46a, vermilion #d4412f/#ff6a4a, vellum #eee6d2/#9aa6d9). No fonts. Styles:
+
+- workspace/column/pane tag (the "trail"): lapis fill, 2 px gold ring, hard
+ shadow 5×5 px vermilion (px snapped to device pixels, no AA), 3 px title
+ text shadow for the file name.
+- pane (the "panel"): gutter (2 cells ≈ the CSS 18 px band) carries the
+ ornament pattern (checker gold/lapis), inset rings gold/lapis/vermilion
+ (2/3/1 px) on the pane edge, soft black shadow from its tier.
+- column tag bar / notice chips: striped gold/lapis (`stripes`, period 4 px)
+ behind the text band, ring gold, hard vermilion shadow.
+- page: lapis-deep. The dot grid sits under the pane BODIES (`dots` pattern
+ with `see_through`, so page-coloured cell backgrounds let it show between
+ glyphs). It is fixed to the pane, not to the text, so it does not track
+ scroll. G13 parallax is retargeted to this body pattern at 0.25× scroll and
+ stays P3.
+
+Where the pixels come from: left band = the pane's own gutter; top = its tag
+row (the band has vertical slack above/below the tagline glyphs); right/bottom
+= 1–2 px rings over the last column/row's side bearings; shadows fall on the
+neighbour's gutter/tag row (§5.2, drawn after the neighbour's cells, §3.4). No
+layout change. If the user wants the CSS
+look with real gaps between panes, that is a separate layout knob (question 1).
+
+---------------------------------------------------------------------------------
+
+## 11. Damage / dirty tracking
+
+No new dirty bits without a measurement. Existing: `needs_frame` (frame-level),
+vaxis cell diff (tty output), wire deltas, web node caches. The pipeline makes
+two savings free: (a) presentation-only animations skip the core render
+entirely; (b) deadlines replace per-tick renders for holding phases. Region
+lists make per-region instance caching possible later, if tracy shows the
+instance build matters.
+
+---------------------------------------------------------------------------------
+
+## 12. What gets deleted or merged
+
+Core: second pane-tag paint, second body paint, grid-notice pass (joined from
+layers), duplicated header hover/selection/caret, `std.mem.swap` of the Surface,
+hover-bit reset + `mark_hover`, per-animation `advance` counters and frame
+constants, `animationActive` (→ `nextWake`), `SceneEffect` (→ shader list),
+`capturePrevious` on idle frames with transitions off; if the user agrees, the
+six core-composed character effects.
+Types: TagLayer + BodyLayer → Layer; layout.zig's Presentation/Track/Easing/
+Animation move to `src/Presentation.zig` and `src/animation.zig` (pure moves).
+GUI: AnimationClock, finishPresentedAnimationFrame, PaintPlan/PaintBatch,
+coverLayers-as-cell-loop (→ page cover), the five inference functions (4),
+crt.zig mapping, crt.frag.glsl, `scene_failures` plumbing folds into the chain.
+Shells: four ack sites, six tick drivers.
+
+---------------------------------------------------------------------------------
+
+## 13. Staged plan (jj changes on a bookmark `render-pipeline`; effects on top of it on `fx`, droppable)
+
+Gate for every stage: `zig build snap` byte-identical, `zig build unit-test`
+and `unit-test -Dplatform=gui` pass, `-Dplatform=web` builds, tracy frame time
+not worse at 200×60. Builds redirected to files; `-Dplatform` always; PARDES_*
+stripped. No visual change until stage 9 unless stated.
+
+| # | change | notes / risk |
+|---|---|---|
+| 0 | GUI capture goldens: a handful of scenes rendered hidden in capture mode, PPM hashes checked in (same machine/driver) | the safety net the GUI stages need; machine- and driver-specific, so a LOCAL gate only, never CI |
+| 1 | pure moves: render → `src/draw.zig`; Presentation/animation out of layout.zig | no behaviour |
+| 2a | clock plumbing: `now_ns` into pump/events, `nextWake`, `advance`, deadlines for linger/hover; all shells pass now and sleep to the wake; six tick drivers deleted; ack moves into pump; capturePrevious only when transitions are on. Animations still compute `frame = floor(elapsed / 16 ms)`, so every curve is identical | pacing is corrected (144 Hz no longer ~25% slow, ssh no longer drifts): a bug fix allowed in this phase. Unit tests step an injected clock by exactly 16 ms, so their numbers do not change. The snapshot harness runs the binary with `--test-clock` (each core-time step is exactly 16.67 ms per tick request, as today), so goldens see the same sequence and `stable` (test/snapshot.zig ~861) is unaffected by wall-time pacing |
+| 2b | (on `fx`, feel-reviewed) Tween/Spring curves, settle rules, OKLab crossfades, new durations from §8.1 | a look change, kept apart from 2a so either can be bisected |
+| 3 | paint functions take `s: *Surface`; delete the swap hack | mechanical, wide |
+| 4 | PLACE: Region list built once; renderPane/tags/notices read their rects from it | wait for tag→Text; the refactor is already replacing BOX_H with `pane.tag_rows` and `p.tagTop/bodyTop(pane, r)` — PLACE absorbs those into the region rects |
+| 5 | JOIN: paint tags/notices/headers once into layers, copy into grid; the grid wins where the copies disagree, and each disagreement is listed for a later decision | goldens are the oracle; wide-grapheme re-clip at the edge (§3.2) |
+| 6 | body layer joined the same way (paint once, copy visible rows) | riskiest join; A/B the old double paint in a temporary test with tag_bottom on and off, then delete it |
+| 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now) |
+| 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed |
+| 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) |
+| 10+ | `fx` bookmark: G1–G3 → feel review → G4 bundled → G5 lapis theme → P2s; tty T1–T2 → feel review → removals of audited effects (after user decision) → T3–T5 | each effect its own change, default off |
+
+Tests to add: fixed-clock animation tests (Tween/Spring closed form, retarget
+velocity continuity, `nextWake` for each holding phase); region placement vs
+old ad-hoc geometry (all layouts, tag_bottom, collapsed, multi-line tags);
+contrast test (absolute targets on the native themes, relative rule on all themes × effect maxima);
+settle tests (`nextWake` is null after every spring settles); Shadertoy prefix compile test with
+ghostty's test shaders (`test_shadertoy_crt.glsl`, `_focus`, `_invalid`) and
+uniform-offset checks against ghostty's `Uniforms` struct; tty byte budget test
+(bytes per animated frame via the snapshot harness).
+
+Risks: the tag refactor moving under stages 4–7; body-layer join parity; glslang
+build time and size (C++, ~minutes on the ~10 min build); machine-specific GPU
+goldens; ssh/tty byte budgets; wire version bump breaks mixed-version attach.
+
+---------------------------------------------------------------------------------
+
+## 14. Recorded open points (agreed with the adversary: not blockers)
+
+1. Selection colours: because ghostty swaps them (§6), Shadertoy shaders tuned
+ on ghostty will see iSelectionForeground and iSelectionBackground swapped in
+ pardes, which fills them by their GLSL names. Documented.
+2. A same-tier cast shadow over a neighbour sits under that neighbour's
+ decor-over rings, because rings are drawn last. Accepted as the look; check it
+ in the feel review.
+3. Gamma is a compromise (sRGB-space blending with pre-warped alpha). Coloured
+ glows keep a slightly dark falloff until the RGBA16F experiment is measured.
+4. The macOS shell breaks at stages 7 and 8 (Layer ABI, mark_hover). Accepted,
+ since the macOS build is ignored for now.
+5. Stage-0 GPU goldens gate locally only.
+
+## 15. Questions for the user
+
+1. Lapis geometry: decorations inside existing chrome (gutter band, tag row,
+ thin edge rings; no layout change — recommended), or real pixel/cell gaps
+ between panes like the CSS page (layout + hit-testing change)?
+2. Remove the six core-composed character transitions (ascii, edges, fall,
+ wave, scramble, typewriter) and GUI zoom/ripple/glitch?
+3. Runtime shader compile: link glslang (C++, from ghostty's vendored pkg,
+ allocator-policy exception), or spawn `glslc` at load time (no new dep, needs
+ glslc installed)?
+4. Focus lift/dim on by default once it passes the feel review, or opt-in?
+5. Stage 2a corrects animation pacing (144 Hz is ~25% slow today, and ssh
+ drifts) while keeping every curve. OK as a bug fix inside the "no visual
+ change" phase?
+6. Cursor blink: pardes never blinked. Keep it off by default (recommended),
+ with an opt-in eased square-wave blink?
+7. Bundled CRT without barrel distortion (exact clicks, recommended), or barrel
+ plus keeping crt.zig's inverse mapping for the bundled shader?
+
+## Decisions (user, 2026-09-28)
+
+1. Lapis is drawn inside the existing pane chrome; no gaps between panes.
+2. Keep every whimsical effect (the six terminal transitions, GUI zoom, ripple,
+ glitch): the problem is their quality, so polish each until it feels good
+ instead of removing it.
+3. Shadertoy shaders are compiled by spawning `glslc`, not by linking glslang.
+4. Focus lift/dim default: undecided, ask when that stage lands.
+5. The animation pacing fix goes into the no-visual-change phase.
+6. Cursor blink is on by default.
+7. The bundled CRT has no barrel distortion, so clicks stay exact.