# 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 bindings 0 to 2 (cut before and after iPalette: SDL GPU binds at most 4 KiB of a block, so one block left the colours after the palette at zero until G4), pardes's own at 3. `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** (decision 3): a user's file is compiled by spawning `glslc` with the build's own flags, prefix and file on its stdin, SPIR-V on its stdout (no file of ours anywhere; std's spawn allocates before fork), on a thread of its own when the file joins the chain, never on the frame path; the result is taken in at the next loop step. The prefix ends in `#line 1`, so glslc's errors count the file's own lines, and they name the file, not ``. Level A redraws once a refresh of the window's display. A failed compile keeps the file's last good pipeline and says glslc's first line as a message; glslc missing is said once, and only files stay off (the bundled passes are compiled with the build, through the same prefix). The files' directories are watched (file_watch shader slots): a change there has each file read and hashed, and one whose bytes moved compiles again; the same bytes (good or bad) compile nothing and say nothing twice. The process that holds the core compiles (shader_build.zig): a local GUI, or a detached session, which sends its attached GUIs the chain with each file's SPIR-V (wire `post`, on attach and on every change), so an attached GUI runs the same passes, levels and ShaderAnimation as a local one and still reads no disk and runs no program. - **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** becomes a bundled Shadertoy file in `shaders/post/` under the same builtin; `scene_effects` and `crt.frag.glsl`/`crt.zig` are deleted. It takes a level, `Crt 0..3` (0 off, `on` the default 2, `off` and bare still work), passed as `pardesLevel`: 2 is the polished rewrite, 3 at least as strong as the old CRT (pixels changed by more than 8 in a channel against a plain frame: 18.3% vs 17.4%). Ripple and Glitch were rewritten too, then removed at the user's word after a live look (decision 8). The chain is `Crt` and `Shader ` in the order they were put in (`Shader off` empties it of files), `ShaderAnimation off|on|always` (default on). The core keeps no clock for it: level A redraws are the shell's. - 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) | removed: a pane whose whole text changed flashed whole (the user: "really bad, just remove it") | — | ### 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: `Host.now` (the shell's monotonic ns) and `Pardes.advance(now)`, which runs one `.tick` per whole 16 ms frame since core time last stood still; `nextWake()` lists every core animation and answers the next frame while anything moves, the wait's end while something only waits (message linger, look-hover delay), null when idle; the pump sleeps to it and draws only stepped frames. No shell posts `.tick` any more (tty's timer thread only wakes the wait; GUI's AnimationClock, web's JS tick bank and the detached server's and the board's ticks are gone; grid mode moves its virtual clock straight to the next wake). `acknowledgePanelPresentation` stays in the shells (revisit at stage 8: grid mode acks `&.{}` on purpose). capturePrevious only when transitions are on. `.tick` stays as the one-frame step `advance` and the unit tests use, so every curve and every asserted number is unchanged | pacing is corrected (144 Hz no longer ~25% slow, ssh no longer drifts): a bug fix allowed in this phase. PARDES_TEST_CLOCK (set by the snapshot harness): tty and the detached server answer a virtual clock that a timed-out wait moves exactly to the core's next wake, so goldens replay the same frame sequence on any machine load. Review fixes: tty's wake timer is interruptible (a newer, shorter request cuts short an older sleep); a wait (linger, hover delay) is jumped to its end in one step, never counted against the 240-frame catch-up cap; an overshoot under 1.5 ms after a step is let go, so 60 and 120 Hz take exactly one step per display frame. Known exceptions: the GUI still polls every 16 ms when idle (SDL events, gamepad, fs tick, smooth scroll depend on it; revisit at stage 8); a minimized GUI renders every stepped frame of a running animation (stage 8) | | 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); done: one `Layer` (src/Layer.zig) with `rows` (0 = no layer) and a `cursor{x, y}`; a tag of N rows is ONE layer of N grid rows (`tagHit` answers the row as `line`, `bodyHit` keeps its meaning), so the per-line layer bases are gone. Wire v8 ships rows, cursor y and the region list in the one bump; v7 and v9 peers are refused in both directions (tests). web: `tag_layer_value` 11 = rows, 12 = cursor y, and app.mjs lays every row. macOS: its Zig side compiles against `Layer`, but pardes.h still sees one row per tag layer (a taller tag shows its first row there) | | 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 Done: the frame is drawn in groups (makeGroups): tier 0, one group per track in paint order (tiers 2 and 3), tier 4 as two groups (the notices; then the guides and the debug box, which the core paints over them), tier 5 (bar cursors), each drawing cells, images, decor. Decor is every rule, rail, thumb, grip mark, spine, the workspace and column rules, the bottom band, notice rules and bar cursors: whole-pixel rects drawn by `decor.frag` over `ui.vert` as cell instances (colour in the ground, coverage in fg.r, blended like the overlay), so each carries its track's transition and clip; a closing pane's comes from the last frame's regions (`Surface.previous_regions`). Only `solid` exists yet: `decor.vert` and the other kinds (§5.1) come with the first effect that needs them. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan; one cover map (coverFrame: layer, grip and offset, focus, anchor, floating) is marked from layers and regions once a frame. New regions: `column` (spines, anchors, the focused column's tint), `guide`, `debug`; `Surface.chrome` is the palette, on the wire in v8 (not yet shipped, so no bump), and an attached GUI draws the same chrome (test). Per-instance clip (`CellInstance.clip_*`, 104 → 120 bytes an instance, ~15% more upload a frame) replaces the vertical transition's scissor. While any pane moves, opens or closes, notices stay in their own panes' groups, under whatever slides over them; tier 4 holds them only when nothing moves. Goldens: 01-12 byte-identical; 13-17 differ only in pixels past the grid (with a picture on screen the image pass left the scissor at the grid's size, cutting every rule end, rail foot and band that runs into the leftover pixels; they now run to the edge as in every scene without one); 16-debug shows the debug box (it was drawn from the grid under the source's context-row layer, so the GUI never showed it there); 18-mid-transition is new (virtual clock, PanelSlide Newcol with the picture, frame 6 of 12: chrome moves with its pane). Other deltas, outside the goldens: a guide over a tag or a context-row body now shows; with WindowOpacity < 100 a layer's bar cursor is ink like the grid's; an attached GUI gains the focus tint, notice rules, spines and the theme's page and caret colours. Deferrals fixed: the GUI sleeps when idle (SDL and queue events wake it; a smooth scroll, a gamepad, the test feed, a shell's kill deadline, a present to retry still poll), and a minimized or occluded window sleeps through animation. Tracy, `gui frame build`, 200x60, terminal output plus scrolling, ReleaseFast, two interleaved runs of ~180 frames: before 992/1015 µs median, after 989/987 µs | | 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) Done: src/gui/Post.zig, shaders/post/ (prefix, crt, ripple, glitch), post.vert. glslc (decision 3) with compileGlsl's flags, off the frame path; ghostty's Uniforms matched offset for offset by a test against a copy, and the prefix's block checked name by name; ghostty's test_shadertoy_crt and _focus compile, _invalid fails with glslc's words. Level A measured (Tracy, 200x60, Crt, idle 10 s): 215 chain-only redraws at 34 µs median CPU each, 6 core frames (957 µs) in the same time; the core's clock stays idle. Input is identity (test: the corner click with Crt on). The three bundled passes were rewritten: Crt without barrel or tube edge, scanlines and mask that average to one, dithered vignette; Ripple as rings in pixels, eased in, lit on their slopes in linear light, dithered; Glitch as short eased bursts of torn bands with an RGB split, keyed to iTime. Before/after stills for the user's judgement. | | 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. 6. Revisit at the first visual stage (from stage 5): the notice layer lost its hover word to keep the grid's behaviour. Notice words are Look/Exec targets, so bring the hover affordance back in BOTH the grid and the layer then. Done right after stage 8: a notice's word under the pointer is lit as a header's is, in the layer and so in the grid's copy joined from it. The same change deleted `mark_hover`, the `Cell.hover` bit and the per-frame reset of it (never set since stage 5; macOS loses its glass hover rect, accepted with open point 4). 7. Revisit at stage 8 (from stage 6): a body without context rows has no layer (paint once, straight onto the grid). If the GUI is to read every body from a layer, give every body one then and measure the copy. The context-row path also still builds the body's text twice (a pre-pass with body_rows 0 decides the context rows, renderBody builds it again at the layer's rows); the paint is single. Measure both there. Decided at stage 8, measured (Tracy, 200x60, TreeContext on a scrolled source, ReleaseFast): the pre-pass text build is 5 µs median and the layer's copies 10 µs per context-row body per frame. Kept as they are: nothing in the GUI reads a layerless body from anything but the grid, which is exact, so a layer for every body would be ~10 µs a body a frame for no pixel. ## 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. 8. (After trying them live) Ripple and Glitch are removed entirely; Crt stays.