summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-16 15:49:12 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:44:42 -0300
commit1551e409c31992437cb2fa864f576d45c8433801 (patch)
treee2fae8451f87b735a1360c7c2e383fdc40165789 /docs
parentbe2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff)
downloadpardes-1551e409c31992437cb2fa864f576d45c8433801.tar.gz
pardes-1551e409c31992437cb2fa864f576d45c8433801.zip
big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh
Diffstat (limited to 'docs')
-rw-r--r--docs/config.md132
-rw-r--r--docs/design.typ216
-rw-r--r--docs/helix-keys.md4
-rw-r--r--docs/macos.md176
-rw-r--r--docs/web.md24
5 files changed, 437 insertions, 115 deletions
diff --git a/docs/config.md b/docs/config.md
index 30962ddb..7e27216b 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -16,15 +16,39 @@ still resolves, because "nothing is there yet" is the answer `Config` exists
to give. There is one case with no path at all: a native launch with no `HOME`
set, which `Config` reports as such.
-`Config` (`SPC f c`, or the word executed anywhere) prints the resolved path
-into a `+Config` output pane, so the machine answers this rather than the list
-above. The path is printed whether or not a file is there — that is the case
-you ask in — and the row is ordinary text, so a right click on it opens the
-file.
+`Config` (`SPC f c`, or the word executed anywhere) opens one refreshable
+`+Config` pane. It reports the startup path and every live config-like value:
+theme, colors, wrapping, tag position, debug mode, the requested shell and the
+executable actually resolved at the last spawn, requested/effective GUI font
+and size, tagline scale, panel transition,
+scene effects, hover delay, platform, native-image support, and (on SDL) whether
+the executable uses live-built shaders or the paired prebuilt shader snapshot.
+Platform-dependent rows say `unsupported` instead of looking like an off or
+empty supported setting: TTY reports Font and scene shaders as unsupported;
+web reports Font, panel transitions, and scene shaders as unsupported. Tagline
+font size is its own row and capability — GUI font selection is native-only,
+while the browser still applies the compiled tagline percentage to its DOM.
+The startup path is printed whether or not a file exists — that is usually when
+it is most useful — and is ordinary selectable text, so a right click on it
+opens the file. Shell follows the same requested/effective/pending model as
+Font. `Compiled default shell` is the command built into the binary; `Shell
+effective (last spawn)` is the executable the native host really chose after
+installation lookup and fallback. A changed request remains pending until a
+terminal is spawned, because the core does not resolve native executables.
+
+The mutable global values live together in the plain `runtime_config.State` record.
+One plain capability record gates the setting registry, leader table,
+`EffectCode`, and report; the compile-time setting table generates both setter
+builtins and their `Config` rows. Exhaustive checks require every table-backed
+toggle, transition, and scene-effect switch to occur exactly once, so those
+generated setting builtins cannot quietly lose their query row or leave a
+renderer switch unnamed. Manual pane-local actions remain with their payload (for
+example an image tag reports its renderer choices); they are not global
+configuration.
The browser build has no local user-config path and does not load this file.
-(Nor does it have the `Font` builtin, or a language backend, or ptys of its
-own — see `docs/web.md`.)
+(Nor does it have `Font`, the effect builtins or `EffectCode`, a language
+backend, or ptys of its own — see `docs/web.md`.)
The format is one existing builtin command per line, using the same spelling
and argument parsing as commands executed inside pardes:
@@ -32,14 +56,16 @@ and argument parsing as commands executed inside pardes:
```text
Theme acme
Font DejaVuSansMono-Regular
+TaglineSize 82
Shell zsh
Wrap
```
A line matches a builtin whose name takes NO argument only as that whole word:
`Kill` runs, `Kill something` does not. Builtins that take one (`Theme`,
-`Font`, `Shell`, `Restore`, `Find`, `Grep`, `Rename`, `WsSymbols`, `Look`,
-`Exec`) take everything after the name as the argument.
+`Font`, `TaglineSize`, `Shell`, `Restore`, `Find`, `Grep`, `Rename`,
+`WsSymbols`, `Look`, `Exec`, `EffectCode`) take everything after the name as
+the argument.
`Theme <name>` wants one of the 228 names in the ring. Do not derive the
spelling — read it off `ThemeSel` (`SPC t t`), which lists every one as the
@@ -60,6 +86,20 @@ terminal's font belongs to its emulator and a browser's to the page — so a
resolve the name by walking the font directories on every lookup, so a face
installed a moment ago is findable.
+`Font` is asynchronous at the renderer boundary. `Config` therefore keeps
+requested name/path, pending state, and the effective face/point-or-pixel size
+as separate facts; a failed request never gets reported as the face on screen.
+Taglines use a distinct face size in both native GUI renderers. Execute
+`TaglineSize <percent>` to change it live, for example `TaglineSize 70`; the
+accepted range is 1 through 100 and the default comes from
+`gui_tagline_font_percent` in `src/config.zig` (82). The native renderer
+remeasures both the glyph and its visible tag band while retaining the body's
+cell grid. The 100% ceiling is deliberate: a tagline remains exactly one
+logical grid row, so a larger face or band would overlap its pane body or a
+neighbour instead of leaving the body grid stable. `Config` reports the active
+percentage. The browser applies the same compiled percentage to its DOM glyphs
+but has no runtime setter.
+
What happens to a codepoint the chosen face has no glyph for differs by shell.
The SDL GUI falls back through a chain it builds itself: embedded Adwaita
Mono, then installed `NotoSansMono-Regular`, `DejaVuSansMono`,
@@ -76,3 +116,77 @@ bad line does not prevent later lines from running. Top-level text that is not
a builtin is not sent to a shell. (`Exec ...` remains an ordinary builtin and
therefore keeps its normal behavior.) Key bindings remain compile-time choices
in `src/config.zig`; this startup file does not remap them.
+
+## Panel and scene effects
+
+Exactly one panel transition is selected at a time. Executing its builtin a
+second time turns it off; selecting another replaces it:
+
+```text
+PanelSlide
+PanelZoom
+PanelDissolve
+PanelAscii
+PanelVertical
+```
+
+All panel transitions start off. Slide uses cubic ease-out and zoom uses an
+overshooting ease-out-back. Dissolve and ASCII compare the last successfully
+presented grid with the new one: cells which did not change are immediately
+canonical, while changed cells cross from old to new through smoothstep or
+stable themed punctuation. Vertical is a pane-lifecycle effect: a newly added
+pane rises from below inside its own fixed box, and a deleted pane's frozen
+content rises out; surviving panes are never animated. The TTY implementation
+performs those operations directly on a copy of the canonical cell grid.
+The SDL and native macOS GUI implementations pass plain panel tracks to their
+GPU shaders, including native image/PDF pixels; layout itself commits
+immediately and remains the one authoritative geometry. DOM web intentionally
+does not expose these builtins: its renderer is selectable HTML/CSS and has no
+canvas or shader stage.
+
+The scene effects are independent switches and can be combined:
+
+```text
+Crt
+Ripple
+Glitch
+```
+
+They share one full-scene shader pass in SDL and macOS. With all three off the
+pass is bypassed. CRT works in linear light with restrained scanlines, mask,
+bloom, curvature, and noise rather than remapping the theme to a strong fixed
+palette; Ripple and Glitch primarily perturb sample coordinates.
+
+`EffectCode <effect-builtin>` opens the build-embedded effect math, host
+paint/submission path, and backend shader/grid sources, for example
+`EffectCode PanelAscii` or, in a GUI build, `EffectCode Crt`. TTY exposes it
+for its grid transitions; native GUI builds
+expose it for transitions and scene shaders. It is absent on web, where no
+effect argument could succeed. Shared
+passes are shown as shared source segments rather than manufactured per-effect
+copies. The command works from an installed binary and does not need the source
+checkout beside it. SDL output also labels its shader provenance. An ordinary
+build prints the live GLSL that `glslc` compiled for that executable;
+`-Dprebuilt-shaders` prints the tracked GLSL snapshot paired with the committed
+SPIR-V instead and labels those segments with their `shaders/prebuilt/` paths.
+`zig build shaders` refreshes both files of every pair together,
+so editing live GLSL without that explicit refresh changes neither half of a
+prebuilt executable.
+
+## Delayed Look preview
+
+The preview is enabled by default. Moving the pointer onto selectable text and
+leaving it still for `look_preview_delay_frames` (2 animation ticks, roughly
+33 ms at the 60 Hz animation cadence) paints a
+subtle theme-derived preview of the exact operand a right-click Look would receive.
+Repeated motion reports in the same semantic grid cell do not restart the
+delay. The preview uses the same side-effect-free word/path expansion as Look;
+it does not focus a pane, move a cursor, install a selection, activate a PDF
+page, or execute anything. Motion to another operand, pointer leave, input,
+pane teardown, and relevant content changes cancel it.
+
+Set this compile-time option in `src/config.zig` to disable the feature:
+
+```zig
+pub const look_preview_delay_frames: ?u16 = null;
+```
diff --git a/docs/design.typ b/docs/design.typ
index 7ee9f1ac..8dd14cdf 100644
--- a/docs/design.typ
+++ b/docs/design.typ
@@ -43,21 +43,35 @@ its own renderer; the core owns everything the user would recognize as pardes.
effect and back; everything with a lifetime — a pty, a window, the
clipboard — is still asked for.],
[*shell*], [one MODULE per platform (tty is one file; gui adds the CRT and
- gamepad files, a C font loader and twelve GLSL shaders; web and macOS each
+ gamepad files, a C font loader and eight GLSL shaders; web and macOS each
add a host language).
Owns the event loop, translates native input
into core events, renders the core's surface, and performs the core's
requested effects (spawn a shell, write a pty, open a link).],
)
+Native shells share `shell_bin`'s OSC 133 startup snippets, but not their
+files. Each host owns a private `mkstemp` pair for its lifetime, writes and
+closes both before the first fork, passes those unpredictable paths directly
+in child argv, and unlinks them at teardown. Concurrent tty, SDL and macOS
+launches therefore cannot truncate, source, or replace one another's startup
+files.
+
This also collapses the prototype's *two* applications into one: the browser
build today is a separate 800-line read-only replay viewer. In the rewrite,
loading a dump of another instance is a first-class feature of the one
application, the way acme handles dumps: `pardes -l state.zon` on every
-platform reconstructs the panes (terminals by replaying their raw VT streams
-into fresh emulators, files, images and PDFs from their bytes — a PDF rides the
-dump's `image` kind, carrying its path, its bytes and the page it was on). The web shell embeds
-a dump and leaves `spawn` unanswered. Same core, no viewer fork.
+platform reconstructs the panes—terminals by replaying their raw VT streams
+into fresh emulators, files, images and PDFs from their bytes. Editable tag
+tails are stored separately from their dynamic live prefixes, and image records
+retain PETSCII, palette, and ASCII renderer choices. A PDF rides the dump's
+`image` kind, carrying its path, its bytes and the page it was on. The web shell embeds
+a dump and leaves `spawn` unanswered. The reader validates weights, scroll
+ranges, pane references, and bounded tag tails before constructing anything;
+invalid base64 fails instead of silently becoming empty content. A PDF record
+whose path cannot be opened—or a build without MuPDF—falls back to an ordinary
+file pane with those exact embedded bytes and its original editable tail. Same
+core, no viewer fork.
The boundary is two data types, both plain values:
@@ -76,7 +90,8 @@ motion plus buttons. The shells do this translation and nothing else with
input: one event vocabulary, four dialects translated at the door.
*Surface* (out): `cols`, `rows`, a grid of cells — grapheme, fg, bg, attrs — a
-cursor, and the pixel attachments. This is the *canonical interface*: it is literally what the
+cursor, pixel attachments, and a bounded list of plain panel-transition tracks.
+This is the *canonical interface*: it is literally what the
tty shell hands to vaxis, cell by cell. SDL rasterizes the same grid through a
glyph atlas; the browser reads a packed copy and patches native DOM cells. If it
cannot be expressed in the surface, it does not
@@ -84,8 +99,9 @@ exist in pardes. (Pixel images ride along as a list of PLACEMENTS rather than
one per pane — a PDF pane contributes every page its viewport intersects, and
pages can be arbitrarily short. The SDL shells blit RGBA, the tty shell falls
back to the petscii matcher, kitty
-graphics when available. The per-pane rectangles live on `Pardes`, not on the
-surface: a shell that wants them asks the core.)
+graphics when available. Transition tracks identify their pane by slot and
+serial and carry only phase, effect, frame, and from/to cell boxes. Canonical
+layout is committed immediately; tracks are finite presentation data.)
*Effect* (out, queued): `spawn{pane, cwd}`, `write{pane, bytes}`,
`resize_pty{pane, cols, rows}`, `open_link`, `new_file{pane, serial}`,
@@ -95,9 +111,18 @@ The core never performs IO for any of these; it asks — and four of the asks ha
answer coming back: `read_clipboard` returns an ordinary `paste` event (or
nothing at all when the shell cannot read the clipboard — most terminals refuse
the OSC 52 read), `lsp` returns `lsp_resp`, `pipe` returns `pipe_resp`, and
-`watch` returns `file_changed` whenever the shell notices the file moved under
-it. This
-is what makes the browser build honest instead of a
+`watch` returns `file_changed` whenever the shell notices a text file or PDF
+moved under it. The tty and SDL hosts currently implement that path with Linux
+inotify; the native macOS host watches both each file and its parent directory
+with debounced DispatchSources, then restats the exact path under a pane-generation
+guard. The file catches in-place writes while the parent follows rename-over
+saves. Text snapshots are filtered by their content hash; PDFs use bounded
+inode/size/time identity and commit it only when equal stats bracket a
+successful transactional MuPDF reopen. A mismatched transaction gets one
+bounded self-retry. This catches rename-over saves without reading a large PDF
+merely to notice it changed or spinning on a malformed one. Web has no
+filesystem watcher. A PDF response retains its
+reading position and pane settings. This is what makes the browser build honest instead of a
fork: a wasm shell simply
answers `spawn` differently (or not at all) — the core does not know.
@@ -111,10 +136,20 @@ if (platform == .web and target.kind == .url)
return .{ .open_link = target.text };
```
-There is one such file by design: `look.zig` holds *what a click on text means* —
-expansion of the word under the click (acme's `isfilec`), path/`:line`
-resolution, url detection, and the per-platform outcomes, kept adjacent so the
-divergence is visible in one screenful.
+There is one such file by design: `look.zig` holds path/`:line` resolution,
+URL detection, and the per-platform outcomes. The core's one `pointerOperand`
+primitive owns click-word expansion and is shared verbatim by right-click and
+the delayed hover preview; that policy stays beside input because it also
+observes live pane selections and wrapped grid coordinates.
+
+Pane implementations are similarly flat and direct: `file_pane.zig`,
+`term_pane.zig`, `image_pane.zig`, `output_pane.zig`, and `pdf_pane.zig` own
+their kind-specific storage and operations. `pardes.zig` keeps the layout,
+input dispatch, cross-pane invariants, and the small calls joining those
+modules. There is no pane vtable or callback layer; the kind is already plain
+data, so a direct switch/call is the shortest boundary.
+The large end-to-end PDF cases live in `pdf_pane_integration_test.zig`, keeping
+pane-specific fixtures and raster assertions out of that core file as well.
= Data structures
@@ -123,24 +158,76 @@ heap-allocated on demand and their contents (file bytes, the yank register, PDF
rasters, tree-sitter state) grow with what you open; everything else is sized
at init.
+User-settable runtime choices are one plain `runtime_config.State`: booleans,
+theme index, owned bounded shell/font strings, requested/effective font facts,
+one panel-transition enum, and scene-effect booleans. A compile-time `settings`
+array generates each setting builtin and the rows of the single `Config`
+query. It has no callbacks and no parallel query registry to drift from it.
+
+Horizontal layout weights are fixed-point integers and geometry rounds
+cumulative boundaries. Splitting a column replaces only its weight `W` by
+`A+B=W` at the same position. Therefore every boundary outside the source
+column is bit-identical before and after the split, including at awkward
+non-dyadic screen widths; only the source and new column can receive movement
+tracks. Vertical splits apply the corresponding rule to the source pane's
+weight.
+
+= Presentation animation
+
+`panel_animation.zig` is backend-neutral data and math: five transitions,
+their easing, exact endpoint progress, stable per-cell noise, and a POD track.
+The core detects opening/moving rectangles when it commits layout and publishes
+only active tracks. It retains the last successfully presented canonical grid
+and a semantic old/new cell mask; unused grapheme bytes do not manufacture a
+change. A separate dense closing-track list is presentation-only state for a
+pane whose functional lifetime has already ended. Pointer input inverts the
+presented slide/zoom/vertical rectangle back to the canonical grid, lets
+unchanged dissolve/ASCII cells through immediately, and rejects closing
+pixels, so pixels and gestures cannot disagree during a transition.
+
+TTY copies the canonical `Surface` grid into a compositor scratch grid, clears
+slide/zoom destinations, then paints moving, opening, and closing panels in
+order.
+Cleared geometry uses the theme page color when it is explicit and the host
+terminal default only for transparent themes, so a light theme cannot flash a
+dark gap. Slide/zoom change the copied rectangle. Dissolve changes only diff
+cells from their old value to their new value; ASCII takes the same cells
+through punctuation; vertical raises only an opening or frozen closing pane
+inside its own clip. SDL supplies old/new glyph data, diff flags,
+final/presented boxes, and effect parameters to the glyph and native-image
+shaders. macOS passes the same records across its plain C ABI and composites
+old/new panel images in Metal/Core Image. Scene `Crt`, `Ripple`, and
+`Glitch` bits share one full-window pass in each native GUI. DOM web is a
+separate platform, not a shader GUI: retaining selectable HTML/CSS is more
+important than duplicating the renderer in canvas, so it exposes neither
+effect family.
+
+`EffectCode <effect>` writes the actual backend math, host submission, and
+shader/grid source segments embedded by the build into an ordinary output
+pane. This makes the implementation inspectable
+after installation and makes sharing explicit: several builtins can quite
+honestly print the same shader with different uniform bits.
+
```zig
Pardes
ncol + col_weight[6], col_terms[6][16], col_n[6] // columns as flat arrays
panes: [16]?*Pane // slot array; id = index
active: usize
drag: Drag // none | select | move | border_v | border_h | tag
- theme_idx: usize // index into themes (228 of them)
- colors_on: bool
+ settings: runtime_config.State // theme, shell/font, display/effect choices
rects: [16]Rect // where each pane landed, this frame
+ panel_tracks: [16]?Track // live panes, serial-guarded
+ closing_panel_tracks: [16]Track // dense visual tombstones, no pane owner
+ presented_cells + changed_cells // acknowledged baseline + semantic diff
effects: [4096]Effect + head/len // a fixed ring
Pane
tag: TagLine // live prefix (cwd/path) + editable tail
- kind: enum { terminal, file, image, pdf } // + independent optional payloads
vt, stream: ghostty-vt Terminal and its stream — on EVERY pane, not just
terminals, which is what lets the same keys and the
same parity suite drive a file and a shell
- file: ?File / image: ?Image / pdf: ?PdfView
+ file: ?file_pane.State / image: ?image_pane.State / pdf: ?pdf_pane.State
+ // payload presence is the kind; none = terminal
vweight: f32
mode: enum { normal, insert, tty } // helix-modal; tty = raw to the pty
// `v` adds a fourth thing to DISPLAY, "select",
@@ -148,13 +235,14 @@ Pane
cursor: absolute body position // rides the scrollback, not the screen
sel: [3]Sel + msel/vsel + sels[63] // per-button block sels, the line and
// char modal ones, and up to 64 cursors
- ovl: ?Ovl // ONE typed run, anchored to an absolute row
- undo: two stacks, not one — ed_undo[64] of overlay snapshots for a terminal,
- // and File.undo of content snapshots for a file
+ ovl: ?term_pane.EditBuffer // ONE typed run, anchored to an absolute row
+ undo: two stacks, not one — term_pane.Snapshot history for an edit buffer,
+ // and file_pane.State history for file content
-File = path + bytes + line index + Syn (tree-sitter highlight bytes)
-Image = decoded RGBA + petscii grid cache
-PdfView = MuPDF document + per-page rasters + cached outline
+file_pane.State = path + bytes + line index + Syn (tree-sitter highlight bytes)
+image_pane.State = decoded RGBA + petscii grid cache
+pdf_pane.State = MuPDF document + continuous layout + search/selection/outline
+ + bounded per-page raster relay + frame placement decisions
```
Layout is arithmetic, not objects: columns are weights over the width, panes are
@@ -170,35 +258,11 @@ big `update` dispatch, not handler objects; no one-line helpers — inline the
four-line scan; assert invariants at entry (`assert(vsum > 0)`); static
allocation at init, arenas per frame.
-The metric was lines of code, and for the rewrite itself it held: 7,626 lines
-replaced the prototype's 12,072 with three backends instead of one and a half.
-It has not held since, and the table below is the honest version rather than
-the flattering one. Everything after the rewrite — PDF, a language backend, a
-fourth shell, 228 themes, multiple cursors — was added, not traded for
-something removed:
-
-#table(
- columns: (auto, auto, auto, 1fr),
- stroke: 0.4pt,
- [*module*], [*at the rewrite*], [*now*], [],
- [core and the pieces that grew out of it — 25 files: pardes, look, syntax, image, dump, builtins, config, output/file/term panes, normal\_input, nested, fonts, themes, …], [4,289], [22,220], [all semantics, all platforms],
- [modal.zig + petscii.zig (pure, unit-testable)], [1,410], [2,569], [carried over],
- [pdf.zig], [—], [1,581], [MuPDF, `-Dmupdf`],
- [lsp/ (seam + in-process ZLS backend)], [—], [1,663], [`.zig` only],
- [shells: tty 1,396; gui 4,968; web 601; macos.zig 1,579], [2,768], [8,544], [translate + render only],
- [main.zig + build.zig], [484], [1,471], [four targets, four codegen passes],
- [*application total* — every `.zig` under `src/`, plus `build.zig`], [*7,626*], [*38,048*], [vs the prototype's 12,072],
-)
-
-Those six rows are a partition: no file is in two of them and none is left out,
-which the old table could not say (its rows summed to 1,325 more than its own
-total, and twenty-six files were in no row at all). What the total deliberately
-does NOT count, because none of it is Zig under `src/`: 2,250 lines of Swift for
-the macOS app and its icon generator, 1,541 lines of C bridging MuPDF, 549 of
-JavaScript, HTML and CSS for the web shell, 216 of C for the SDL font loader,
-`mupdf.zig` (355) at the root, and `tools/` + `build/` (896). The test tree is
-another 5,234 lines of Zig across eight harnesses, plus 1,028 of Swift and 610
-of JavaScript — against 831 at the rewrite.
+The rewrite used source size as a pressure toward direct code, and that remains
+useful when a refactor deletes duplicate policy or state. A checked-in line-count
+inventory does not: it goes stale whenever a pane kind, backend, or generated
+asset moves. Measure the current tree when making that comparison; keep this
+document about ownership and invariants that should survive the next edit.
Two things that number is not. It is not one program's worth of growth — the
macOS and web shells and the PDF and language work are four products sharing a
@@ -214,14 +278,18 @@ captures the rendered grid — text, cursor, and per-cell style runs — through
its own ghostty terminal. Goldens are generated from the old binary
(`zig build snap -- --update`); the new binary must reproduce them byte for
byte (`pardes-snap pardes/zig-out/bin/pardes`). Eighteen scripts covered the
-checklist in Appendix A at the rewrite; eighty-seven cover it and everything
+checklist in Appendix A at the rewrite; ninety cover it and everything
since. All pass. Determinism pins: fixed workdir paths (they
appear in tags), a controlled `$HOME` with `PS1='$ '`, `LC_ALL=C`, and a
grid-stability sync primitive instead of timing guesses.
Three deliberate deviations surfaced by the oracle, kept after review: the
-greeting `ls` waits for the shell's first output (the prototype raced bash's
-startup and won only by allocator luck); dump files compare with base64 pty
+greeting `ls` waits for the exact OSC 133 B input mark after the real resize
+(the prototype raced bash's startup and won only by allocator luck); shells
+without prompt integration omit that cosmetic greeting rather than guessing.
+Commands which create a fresh shell are owned by its terminal pane until the
+host reports the actual prompt capability, then wait for the same mark when it
+exists. Dump files compare with base64 pty
history elided (it encodes prompt-redraw micro-timing, not state — the cleaned
text fields are the contract); and typed insert runs don't survive a dump
replay (they are an overlay, not pty bytes — the prototype's replay viewer had
@@ -252,12 +320,14 @@ everywhere but the web, lazy), ZLS, mvzr (the regex engine behind `s`/`S`), and
zig-tree-sitter + 26 grammars — all pinned
through `zig fetch` and wired in `build.zig`. Generated during the build:
`highlights.scm` → an options module; the vendored helix/zed theme
-sources → the generated half of the theme ring; the tracked `.zig` sources →
+sources → the generated half of the theme ring; working-tree `.zig` sources →
the web shell's read-only archive; eight GLSL shaders → SPIR-V. That last one is
the only build input wanting a tool a stock machine lacks (`glslc`), so it is
-also the only one whose output is committed: `zig build shaders` writes
-`shaders/prebuilt/*.spv` and `-Dprebuilt-shaders` embeds that copy rather than
-shelling out, which is what lets a gui build need nothing but a C toolchain. The
+also the only one whose output is committed: `zig build shaders` refreshes each
+paired `shaders/prebuilt/*.spv` binary and `.glsl` source snapshot together.
+`-Dprebuilt-shaders` embeds that exact pair rather than shelling out, so
+`EffectCode` cannot describe different shader text from the binary on screen;
+this is also what lets a gui build need nothing but a C toolchain. The
tutor and the embedded font are plain `@embedFile`s, not codegen. Debug builds are
incremental for the seconds-loop; release builds are the product.
@@ -292,7 +362,8 @@ everything below that the prototype never had.
*Layout.* Columns by weight (≤6), panes by vweight (16 panes in total, not
per column); global topbar;
per-pane gutter (move box + scrollbar) and tag row; `splitBelow` shrinks only
-the source (cursor row kept visible); dying pane's weight absorbed by one
+the source (cursor row kept visible); `Newcol` takes width only from the source
+column and cannot resize any unrelated column; dying pane's weight absorbed by one
sibling; emptied column hands width to a neighbor; border-drag resize on a
pane's own trailing edge (v and h), hover shows `╎`/`╌` glyph-only hints;
move-drag via the gutter box with preview; Alt-n new shell below, Alt-c move
@@ -321,8 +392,10 @@ has beyond a path: `` @`ls -la` `` is taken WHOLE and runs as a command rather
than opening as a file, and `@p7:10:5` addresses a live pane by number for the
things — terminals, output buffers — that have no path to name.
Middle+left chord: kept
-left selection appended as trailing CLI argument. Wheel: scroll hovered pane,
-batched.
+left selection appended as trailing CLI argument. A stationary pointer gets a
+delayed, theme-derived highlight of the exact side-effect-free selection that
+Look would expand; it neither focuses nor installs that selection, and pointer
+leave/input/content invalidation cancels it. Wheel: scroll hovered pane, batched.
*Modes.* normal: helix motions (`h j k l w b e W B E 0 $ ^`, `f F t T` and
`Alt-.`, counts, `gg ge gh gs gl g| G`, `Ctrl-d/u/f/b`, `zt zz zb zj zk`),
@@ -349,9 +422,10 @@ cannot clobber what the desktop was holding. A paste from an outer terminal
arrives bracketed, as one `paste` event.
*Tag.* Live prefix (mode indicator, cwd or path) + editable tail with the
-full modal editor; defaults `New Del` / `Save New Del`; an image tag is
-`img <path> New Del` like any other (its renderer toggles are builtins under
-`SPC t p/l/a`). Topbar:
+full modal editor; defaults `New Del` / `Save New Del`; an image tag reports
+`img petscii:<on|off> palette:<commodore|terminal> ascii:<on|off> <path>`
+before the ordinary tail (its renderer toggles are builtins under `SPC t
+p/l/a`). Topbar:
`New Newcol Find Grep Help Tutor Dump NextColor Debug Kill` — execute-only
(left click inert);
Colors and Crt left it for their leader paths.
@@ -408,10 +482,12 @@ scrollbar, Colors/NextColor, and link-LOOK opening a new
tab (new — the prototype has no web link handling). One-finger touch is
deliberately complete by itself: tap = LOOK; drag past a small slop = natural
scroll, with no LOOK on release. A build-generated read-only archive lets LOOK
-open the current contents of Git-tracked Pardes `.zig` files despite the web
+open the current contents of tracked or new/nonignored Pardes `.zig` files despite the web
shell having no host filesystem. The published launcher dump is captured from
-a running Pardes TTY after `git ls-files '*.zig'`, so its complete terminal
-listing is the set the user can open. The freestanding module carries no host
+a running Pardes TTY after
+`git ls-files --cached --others --exclude-standard -- '*.zig' | sort`, so its
+complete terminal listing is the same tracked-plus-new/nonignored set the user
+can open. The freestanding module carries no host
libc, SDL, or WebGL; Tree-sitter's C runtime and the selected parsers link into
the module through a tiny local ABI shim (Zig is the compact web default). A body gesture retains
tap-LOOK/drag-scroll, but finger-down on
diff --git a/docs/helix-keys.md b/docs/helix-keys.md
index addc23da..2aff75cd 100644
--- a/docs/helix-keys.md
+++ b/docs/helix-keys.md
@@ -95,7 +95,7 @@ language-backend queries, and the shell pipe.
| `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — normal/tty modes only | pardes' own window handling (helix window mode skipped, section C). Runs the SAME `Left`/`Down`/`Up`/`Right` builtins `SPC w h/j/k/l` runs; kept alongside the leader because a pane in raw **tty** mode never sees `SPC` (the shell owns it), so this is the only keyboard way out of one. Insert mode owns `Ctrl-w` = delete-word-back, so a tag being TYPED into swallows it; from a tag in normal mode (`:`) it moves focus to the neighbour's BODY, while the bare letters `h/j/k/l` there move to its TAGLINE (next row) | pardes-specific |
| `Alt-n` | new terminal below (any mode) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific |
| `Alt-c` | move active terminal to a fresh column (any mode) | helix `Alt-c` is change-noyank; the pardes window op wins (do-not-touch contract). `Alt-d` + `i` covers the behavior | waived (`alt-c-window-op`) |
-| `Space` (normal, body) | the pardes LEADER: a key path from here runs a BUILTIN with no arguments — the same builtins the topbar and the tags hold. `SPC k` Kill, `SPC d` Del, `SPC f s` Save, `SPC f n` New (an empty temporary file focused in the calling pane's column), `SPC f f` Find (fd over the pane's directory, results into `+Search`), `SPC f g` Grep (grep -R over the CONTENTS of every pane's directory, the ones another pane already covers dropped, rows relative to the asking pane's own directory, results into `+Search`; like Find it takes an ARGUMENT — `Grep foo` executed, or a selection chorded onto the word, searches that and skips the input), `SPC h t` Tutor, `SPC c n`/`SPC c d` Newcol/Delcol, `SPC y/Y/p/P/R` the five clipboard words (next row), `SPC t d/c/n/r` Debug/Colors/NextColor/Crt, `SPC t t` ThemeSel (the whole theme ring as `Theme <name>` rows in an output buffer — rows that are COMMANDS rather than places, so a theme is previewed by EXECUTING one, Tab or a middle click; `n`/`N` do not run them, they step past to the next look-able text on the ring), `SPC t p/l/a` Petscii/Palette/Ascii (an image pane's renderer toggles — the words its tag used to spell out, so the bar stays a bar), `SPC s d`/`SPC s r` Dump/Restore, `SPC w h/j/k/l` Left/Down/Up/Right (directional pane focus — the `Ctrl-w` prefix's four moves as builtins), `SPC j o`/`SPC j i`/`SPC j j`/`SPC j l` Back/Forward/Last/Jumplist (the jump stack: walk it back, walk it forward, hop to the pane before this one — what body-normal Esc runs — and read the stack as a clickable buffer). `?` at ANY depth opens the Help builtin listing what the prefix can still reach (`SPC ?` = all, `SPC h ?` = the docs group), into a `+Help` output buffer. The pending path shows at the right edge of the active pane's tag; Esc — or any unmapped key — abandons it | helix spends Space on pickers/LSP (section C); pardes has neither, and acme's builtins are what a leader is for. Enum-derived: the paths, the Help lines and the execute dispatch all fold out of one `Builtin` enum at comptime. Normal mode on a BODY only — tags are always insert, tty keys belong to the program | pardes-specific |
+| `Space` (normal, body) | starts the pardes LEADER: a key path runs the same builtin words used by tags and the topbar. The main groups are `f` files, `h` docs, `c` columns, `t` toggles/effects, `a` panel animations, `s` session, `j` jumps, `l` language, and `w` directional focus; `SPC ?` lists every path and `<prefix> ?` lists one group in `+Help`. The pending path appears on the active pane's transient body/message row. Esc or an unmapped key abandons it. Paths, Help rows, and dispatch are all generated from the builtin registry at comptime; the leader applies only to a BODY in normal mode because tags and tty programs own their input | pardes-specific; helix spends Space on pickers/LSP (section C), while pardes uses acme-style executable words |
| `SPC y` `SPC Y` `SPC p` `SPC P` `SPC R` | helix's clipboard menu on helix's own letters: yank the selection to the system clipboard (`ClipYank`) or the PRIMARY selection alone (`ClipYankMain`), paste the system clipboard after (`ClipPaste`) / before (`ClipPasteBefore`) the selection, replace the selection with it (`ClipReplace`) | the ONLY five words in pardes that touch the desktop's clipboard — `y`/`d`/`c`/`p`/`P`/`R` and the acme cut/paste chords are the internal register alone. Builtins rather than bare chords because a leader path names a builtin: they land in Help's index and are executable words like every other verb. One divergence: pardes keeps a single register VALUE where helix keeps one per range, so `SPC y` at N cursors joins them with newlines (`Pardes.setYank`, the divergence `msel-yank-paste` already waives). On a tty the write is OSC 52 out and the READ is OSC 52 back, which many terminals refuse or gate — so `SPC y` works there and `SPC p` can be a no-op | out of corpus |
| a paste from the OUTER terminal | one `Event.paste`, spliced in at the cursor | the tty shell enables bracketed paste and coalesces `paste_start`..`paste_end` into a single event; before that the bytes arrived as individual key presses and normal mode RAN them, which is how a pasted `d` deleted a line. The bytes deliberately never enter the yank register — clipboard and default register are separate stores in both directions | pardes-specific |
| `/` (any pane) | pardes' own plain-substring search into a `+Search` output buffer: the tag takes the pattern, Enter fills the buffer, and its rows are ordinary look targets. Enter also GOES to the first row — the buffer is focused and then the step `n` is and the look Enter is run in it (`Pardes.lookFirstHit`), so `/foo` lands on the first hit with the matched span selected. A pattern that matched nothing opens its empty buffer and moves nothing | KEEP, do not touch; not in the corpus (helix `/` is regex search). Find and Grep answer with OTHER files and deliberately do NOT jump. The stepping half is the next row | pardes-specific |
@@ -348,7 +348,7 @@ Files (all in `test/hxcases/`):
(one yank register, not one value per range), and `sel-regex-caret` /
`sel-regex-dot-newline` (mvzr is not the Rust regex crate: no
multi-line `^`/`$`, and `.` matches a newline).
-- `test/hxdiff.zig` builds `pardes-hxdiff`, which drives the sans-IO core
+- `test/hxdiff.zig` builds `pardes-hxdiff`, which drives the core
headlessly at 80x24 (22 body rows, matching helix's 22 text rows).
Run it:
diff --git a/docs/macos.md b/docs/macos.md
index 12e598b5..b800513f 100644
--- a/docs/macos.md
+++ b/docs/macos.md
@@ -44,6 +44,11 @@ the view and drives its own frame clock, and the ABI grows a `platform` pointer
field carrying the `NSView*` — one field, because the rest of the seam does not
change. Nothing here is designed to make that harder.
+That does not rule out a *postprocess*. CoreText is still the renderer and the
+cell ABI is unchanged, but shader effects draw that same frame into a retained
+bitmap and feed it through Core Image kernels on one Metal context.
+There is no second glyph atlas, pane renderer, or view pointer in the ABI.
+
## Why the ABI mirrors src/web.zig
The browser and a Cocoa app are the same host, and the browser proved the shape
@@ -78,6 +83,11 @@ asking for a thin insert caret instead of a block. `Surface.images` crosses
separately as `pardes_frame_images` / `pardes_frame_image_list` — see "Pixel
attachments" below.
+`pardes_scene` is the matching full-window snapshot: a bit each for CRT,
+ripple, and glitch, plus the 60 Hz time/frame that animates them. It is returned
+by value, so the renderer never holds a pointer into live core state. A zero
+flags word is also the fast-path contract: draw the CoreText frame directly.
+
**Input.** `pardes_key` takes a codepoint and the host's already-composed text,
because composition is AppKit's job and the core only ever wants finished
characters. Keys that carry no text are the four ASCII controls (enter, escape,
@@ -123,15 +133,19 @@ because it owns the filesystem side too.
**Lifecycle.** `pardes_init` returns 0 or an opaque nonzero code,
`pardes_deinit` tears down, `pardes_tick` drains pty output plus the effect
queue and returns whether anything changed, `pardes_should_quit` reports the
-Exit builtin or the last pane closing, and `pardes_animating` says a theme
-transition wants ~60 Hz ticks until it settles — the one thing in an otherwise
-event-driven frontend that redraws on a clock, handled in `tty.zig` by sleeping
-the loop and forcing a tick.
+Exit builtin or the last pane closing, and `pardes_animating` says
+`pardes_animation_tick` wants a ~60 Hz call until it settles — the one thing in
+an otherwise event-driven frontend that redraws on a clock. A persistent scene
+effect deliberately keeps that clock armed until its builtin turns it off.
+Ordinary input and pty pumps never count as elapsed animation frames.
The ordering contract is the part a header cannot enforce. **Init with the real
-grid size.** The core defers each shell's greeting until it has seen a resize:
-`sync()` in `src/pardes.zig` only emits the opening `ls` once `resize_count > 0`
-and the pty has produced its first prompt. Setting `Options.cols`/`rows` alone
+grid size.** The core defers each integrated shell's greeting until it has seen
+a resize: `sync()` in `src/pardes.zig` only emits the opening `ls` once
+`resize_count > 0` and parsed OSC 133 B says the prompt has handed the cursor
+to input. An unintegrated shell has no reliable readiness signal, so it skips
+the cosmetic greeting; an explicit command still releases after its successful
+fork. Setting `Options.cols`/`rows` alone
never bumps that counter, so init must turn its arguments into an actual resize
event the way `src/web.zig` does immediately after construction — and the size
must be true, because the first `forkpty` takes its winsize from the core's
@@ -252,12 +266,12 @@ most deliberate twist there is and the one a stale velocity sample would fling
hardest. And a finger back down (`pardes_rotate(0)`) catches a coast in
progress, the way a hand catches a dial.
-The coast itself is spent by `pardes_tick`, one fixed 1/60 step per tick with a
-0.94 decay, and it makes `pardes_animating` true for as long as it lasts — so
-it rides the same 16 ms re-pump a theme transition does and needs no clock of
-its own. Fixed rather than measured on purpose: one fling then spends the same
-travel every time, which is what lets `rotate.snap` assert it instead of
-asserting the machine's timer jitter.
+The coast itself is spent by `pardes_animation_tick`, one fixed 1/60 step per
+scheduled frame with a 0.94 decay, and it makes `pardes_animating` true for as
+long as it lasts — so it rides the same 16 ms re-pump a theme transition does
+and needs no clock of its own. Fixed rather than measured on purpose: one fling
+then spends the same travel every time, which is what lets `rotate.snap` assert
+it instead of asserting the machine's timer jitter.
Both halves are goldens. `rotate.snap` turns the dial at 4° per 100 ms (40°/s,
under the floor) and asserts the screen is byte-identical across the release,
@@ -471,14 +485,18 @@ can disagree. `pardes_font_take` hands it over once, the same take-and-clear
shape as the haptic, and the view loads it with
`CTFontManagerCreateFontDescriptorsFromURL`, picks the untraited cut out of a
collection, derives bold and italic from it, and re-measures. A file it cannot
-wear leaves the screen exactly as it was — a terminal that cannot draw has no
-way back out of itself.
+wear crosses back through `pardes_font_reject` and leaves the old metrics
+exactly as they were. Success crosses through `pardes_font_ack` with the
+effective PostScript name and point size. Requested and effective values are
+therefore distinct, queryable facts rather than a request being mistaken for
+what is on screen.
-Zoom does not touch the core at all. Cmd+=, Cmd- and Cmd+0 change the point size,
-`Metrics` is rebuilt, and the new cell is reported through the same resize path
-a window drag uses; the core reflows to a different number of columns and knows
-nothing about points. Cmd+= rather than Cmd++ because AppKit matches the
-character and `=` is what is under the finger.
+Cmd+=, Cmd- and Cmd+0 change the point size, rebuild `Metrics`, and report the
+new cell through the same resize path a window drag uses. They also observe the
+effective face/point tuple through `pardes_font_observe`, so the single `Config`
+report follows a host-owned zoom without resolving a pending face request. The
+initial system face is observed the same way. Cmd+= rather than Cmd++ because
+AppKit matches the character and `=` is what is under the finger.
Both are machine-checked in `test/macos-snapshots/font.snap`, which needs two
different kinds of assertion because a snapshot is the core's cell buffer and
@@ -551,18 +569,40 @@ Turning this on also changes what an IMAGE pane is here: it was the PETSCII
glyph-art fallback, the same one a terminal without kitty graphics gets, and it
is now the real pixels.
+## Live file reload
+
+`FileWatcher.swift` gives each watched pane a vnode source on the file and one
+on its parent directory. The file catches in-place writes; the parent is
+essential because editors commonly save by renaming a fresh inode over the
+path. After the 45 ms debounce the file source is rearmed on the current inode,
+then the event returns to Zig with the pane's watch generation. Replacing or
+closing a pane advances that generation, so a callback already queued for the
+old occupant cannot reload the new one.
+
+The callback only wakes the ordinary main-thread pump. `pardes_tick` checks the
+exact owned path, filtering unrelated changes in the same directory, duplicate
+vnode events, and Pardes's own saves. Text panes compare and adopt a bounded
+byte snapshot by content hash. PDFs can be much larger than that bound, so they
+compare inode/size/time metadata and let MuPDF reopen the pathname directly.
+That identity is committed only when equal stats bracket a successful
+transactional reopen; a mismatch receives one self-scheduled retry, so it does
+not depend on a second vnode edge and cannot spin forever on a malformed stable
+file. Reading settings survive and derived page data is regenerated. A
+malformed PDF therefore leaves the last good document usable and remains
+retryable after the next real write.
+
## Themes, live
Two bugs lived here, and they were the same bug.
`ChromeTheme` fades between themes over ten 16 ms steps, advanced by a `.tick`
-event. The tty and SDL loops call `core.update(.tick)` on their own clocks;
-this host has no loop of its own, so nothing advanced it — `pardes_tick`
-drained ptys and reported `themeAnimationActive()` back without ever stepping
-the transition. The fade therefore never moved and never ended: every tagline
-kept the *previous* theme's colours until the next launch, and the 16 ms
-re-pump in `AppDelegate.pump` spun at 60 Hz for the rest of the session. The
-pump is the clock, so `pardes_tick` steps it.
+event. The tty and SDL loops call `core.update(.tick)` on their own clocks; the
+native host schedules the same clock explicitly through
+`pardes_animation_tick`. `pardes_tick` only drains work: if every input pump
+also advanced the transition, a burst of key or pty events could collapse a
+ten-frame fade into one display frame. The scheduled callback advances once,
+then pumps effects and redraws, and re-arms itself only while
+`pardes_animating` remains true.
`pardes_theme_bg` is the other half. The window background behind the titlebar
and behind a live resize was a hand-agreed `#121212` in two files; it is now
@@ -571,6 +611,65 @@ chrome's, because document backgrounds switch the instant the theme does while
chrome fades. `window.appearance` follows its luminance, so wearing `acme` no
longer leaves a dark titlebar over a cream grid.
+## Scene effects
+
+`Crt`, `Ripple`, and `Glitch` are three switches over one postprocess, not three
+stacked filters. Their canonical macOS source is `shaders/crt.ci.metal`, which
+the build installs as `pardes.app/Contents/Resources/crt.ci.metal`. The Swift
+shell loads that exact asset with `CIKernel.kernels(withMetalString:)` and runs
+it through a `CIContext` created from the system Metal device. The source is
+also a Zig build import, so `EffectCode` can print what the app executes when
+the source checkout is absent.
+
+The ordinary CoreText/attachment/cursor pass is one function. With any scene
+bit or panel track it targets a retained, backing-scale bitmap; kernels then
+sample the complete frame into the flipped view. PDFs, image panes, taglines,
+rules, and the caret therefore receive the same effect. With every bit off and
+no panel track the bitmap and Core Image context are bypassed entirely. Ripple
+and glitch alter sample coordinates only, preserving theme colors; CRT works
+in linear light with restrained bloom, scan/mask, vignette, hum, and noise
+instead of applying a broad color remap.
+
+The scene kernel alone receives a `clampedToExtent` image and the final output
+is cropped back to the original finite extent. Coordinate tears and chroma
+samples therefore clamp to the edge exactly like SDL's scene sampler instead
+of acquiring transparent-black seams from Core Image's finite source image.
+
+The Zig side owns the clock. `pardes_animation_tick` increments its wrapped
+60 Hz frame only while a scene bit is active, and `pardes_scene` derives seconds
+from that integer. Input bursts cannot accelerate the shader.
+
+Pointer input follows the same destination-to-source transform as the last
+presented scene frame before it is divided by the cell metrics. The view keeps
+that exact `pardes_scene_s` snapshot and mirrors the Metal barrel, ripple and
+glitch sampling arithmetic in `ScenePostprocessor.sourcePoint`; pixels outside
+the CRT tube have no cell. The resulting displayed-grid cell then reaches the
+core, whose panel-track mapping resolves it to canonical pane content.
+
+## Panel transitions
+
+`pardes_frame_panel_track_list` publishes the core's plain `Track` records
+without a host-side animation model: pane serial/id, phase, effect, frame, and
+logical-cell `from`/`to` boxes. The C layout and every field offset are asserted
+against the Zig extern struct on Linux. The exported list is already stable
+paint order—moving panes by slot, then opening panes by slot—so Swift only
+consumes it.
+
+CoreText still renders one canonical complete frame. When tracks exist, the
+same retained bitmap used by scene effects is fed through two kernels in
+`shaders/crt.ci.metal`: `pardesPanelClear` removes every final target first,
+then `pardesPanel` samples each target into its eased presented box. Slide uses
+ease-out cubic, zoom uses ease-out-back, dissolve uses stable pane/cell noise,
+and ASCII materialization replaces not-yet-revealed cells with procedural
+punctuation. Because the input is the finished bitmap rather than a glyph-only
+batch, backgrounds, glyphs, taglines, rules, the caret, PDF pages, and image
+panes move and dissolve together. Scene CRT/ripple/glitch runs once after the
+panel composition.
+
+With no scene bit and no panel track the retained bitmap, Core Image context,
+and Metal passes are bypassed. `EffectCode Panel*` embeds this actual Metal
+file plus its direct Swift owner, the same files the app executes.
+
### Transparent themes
A theme with `bg = null` — the curated `dark`, and every vendored
@@ -794,7 +893,12 @@ vocabulary (`start`, `wait`, `stable`, `text`, `key`, `snap`, `command`,
exists here: `fingers <n>`, `force`, `rotate <degrees> [gap_ms]`, `rotate_end`,
`drop <path> <col> <row>`, `scroll <rows> <col> <row>`,
`haptic <none|exec|look>`, `nsclick` (the AppKit-event path, as opposed to
-`click`'s direct entry-point call), `font <name>`, `zoom` and `clipboard`.
+`click`'s direct entry-point call), `font <name>`, `font-size <points>`, `zoom`,
+`tracks <phase:effect>...`, `draw-effect`, and `clipboard`. The boot script uses
+`tracks` followed by `draw-effect` to assert the moving/opening ABI order and
+that the runtime Metal owner actually accepted the panel frame (a raw fallback
+fails); the font script checks every initial/adopted/zoomed effective point
+size without changing its grid goldens.
The dial's two extras are what
make momentum testable at all: the optional gap is a real sleep before the
event, so a script can say how FAST the dial is being turned, and `rotate_end`
@@ -815,6 +919,11 @@ every pixel comes out identical, which is what keeps `draw(_:)` honest — `snap
reads the core's cell buffer and would be perfectly happy with a `draw` that
returned on its first line.
+The Linux loop proves the new C layout, flag encoding, clock wrap, embedded
+kernel source, header syntax, and static library. Compiling Swift, runtime Metal
+kernel compilation, and comparing processed pixels remain `macos-e2e` work on
+a Darwin host; Linux has neither AppKit nor Apple's Metal runtime.
+
Goldens are hermetic: a fake `$HOME` with a pinned `PS1`, `Shell bash` in the
config (fish's prompt carries a hostname), `LC_ALL=C`, `PARDES_NOTIME=1`, and
`TMPDIR` inside the per-script world so that `New`'s document has a reproducible
@@ -851,6 +960,11 @@ this matter, are both in `pardes_frame` rather than in the view — it re-render
every cell of the grid on every frame, and `draw(_:)` ignores its `dirtyRect`
for exactly that reason.
+Those figures are the direct path with all scene bits off. An enabled scene
+adds an offscreen CoreGraphics frame, one Core Image/Metal kernel, and
+presentation of its result. That opt-in cost has not been measured on the M2
+used for the table and is not folded into the direct-path claim.
+
## Not implemented
- **The `lsp` effect.** Needs a worker plus a snapshot of the pane's path and
@@ -862,12 +976,6 @@ for exactly that reason.
- **The `pipe` effect.** Selection filters need `pipeRequest(id)` copied into a
job, a worker to run the command, and a `pipe_resp` event back. Same shape as
`lsp`, one more response type.
-- **The `watch` effect.** `watchPane` is inotify and returns silently off Linux
- (`ponytail:` at `src/tty/tty.zig:1183`). macOS wants the FSEvents half of
- `std.Build.Watch`, which the standard library already has as
- `Build/Watch/FsEvents.zig`. Without it the core simply never receives
- `file_changed`, a state it tolerates because the browser has no filesystem
- either.
- **IME and marked text.** Only finished characters reach `pardes_key`, so a
dead key composes nothing and Option is Alt rather than a compose modifier.
Real composition means implementing `NSTextInputClient` *and* giving the core
diff --git a/docs/web.md b/docs/web.md
index ddc09d35..a6e2e923 100644
--- a/docs/web.md
+++ b/docs/web.md
@@ -18,6 +18,22 @@ array. `src/web/app.mjs` reads that array in one linear pass and patches stable
DOM nodes. Core effects become browser operations (links, downloads, clipboard)
or `pardes-io` custom events for a process-capable embedding host.
+Cell attribute bit 7 carries the core's tagline font role. The DOM keeps every
+cell at the body grid's fixed width and height, but scales and centres the
+tagline glyph using the build-time `gui_tagline_font_percent`; the percentage is
+exported by the module rather than duplicated in JavaScript. Animation time is
+also host-independent: `requestAnimationFrame` time is accumulated into 60 Hz
+core ticks, so 120/144 Hz displays do not accelerate frame-count transitions
+and a returning background tab has bounded catch-up work.
+
+The `web` platform is deliberately not one of the shader-capable native GUI
+shells. It exposes no `PanelSlide`/`PanelZoom`/`PanelDissolve`/`PanelAscii`/
+`PanelVertical` or `Crt`/`Ripple`/`Glitch` builtins: applying those faithfully
+would require a second canvas renderer and give up the DOM renderer's
+selectable/accessibility contract. Theme fades and the delayed,
+side-effect-free Look hover remain grid animations and continue to use the
+fixed 60 Hz ticks above.
+
Build a replay from a dump:
```sh
@@ -37,12 +53,20 @@ file-or-directory — is native-only, because the wasm module roots at
`src/web.zig` and never links `main.zig`. State comes from the embedded dump
instead.
+Web LOOK's source archive follows Git's working-tree view: tracked `.zig` files
+plus new, nonignored ones, sorted by path. The checked-in browser replay is a
+real native TTY dump of that same selection; its top and bottom launcher
+snapshots jointly cover the complete list before opening `build.zig`.
+
What is genuinely absent is narrower than "no IO". The module has no threads
and no host filesystem, so there is no startup config file, LOOK resolves
against the build-generated source archive rather than disk, and no language
BACKEND is compiled in (`zls_backend` is off for wasm — which also means
`lsp.supports` is empty, so the core never even raises a language query there;
`web.zig`'s prong for it is waiting for a host that links one).
+Replay terminals retain the same core shape but use Zig's failing IO value;
+this keeps the module freestanding without instantiating POSIX threaded IO that
+the browser can never call.
The EFFECTS themselves are all still emitted, each as a numbered code across
the ABI: `spawn` 1, `write` 2, `resize_pty` 3, `open_link` 4, `save_file` 5,