diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 09:21:26 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | cc5323772d5acd07376c561e8e08eaf365b7d22c (patch) | |
| tree | a65116ac07901e778e158866ded38213c0fb4b2d /docs | |
| parent | a9de2b51ab76a43250a4d6c41a0ddd54e6970049 (diff) | |
| download | pardes-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')
| -rw-r--r-- | docs/render-pipeline.md | 875 |
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. |
