1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
|
# 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 `<stdin>`. 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). No file watching: `Shader <path>` twice
(out, then in) compiles it again.
- **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 <path>` 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) | 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: `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.
|