# DOM web backend The web backend keeps the existing Pardes core and replaces the old Emscripten/SDL/WebGL shell. Zig produces a `wasm32-freestanding` module whose only imports are the four browser capabilities listed below — no libc, no WASI, no runtime. JavaScript owns `requestAnimationFrame`, browser input and window sizing; the CORE owns the loop and JavaScript calls one iteration of it per frame. HTML and CSS render selectable, accessible text. There is no canvas. The default web build links Tree-sitter's C runtime and the Zig grammar inside that same import-free module. A small freestanding compatibility layer supplies the C ABI locally, so source panes retain the core's Tree-sitter highlighting without Emscripten, WASI, or browser libc imports. Other grammar tiers remain selectable with `-Dtree-sitter=minimal` or `-Dtree-sitter=full`. `src/web.zig` is the narrow WASM ABI. Events enter through exported functions that hand them straight to `Pardes.update`. `pardes_tick` is one `Pardes.pump` — queued input, effects, render, present — and the host's `present` packs the canonical `Surface` into a 20-byte cell array that `pardes_frame`/`_ptr`/`_cols`/`_rows` describe. `src/web/app.mjs` reads that array in one linear pass and patches stable DOM nodes. Animated time is JavaScript's alone. `pardes_animation_tick` spends one fixed 60 Hz step and is the only entry that advances a transition, exactly as the AppKit shell keeps its display clock apart from its pumps: if every input pump also advanced a fade, a burst of keys would collapse ten frames into one. `pardes_tick` never advances animated time itself, and no longer has to undo anything to avoid it: `Pardes.pump` does not post a tick for its caller, since only the host knows when a real frame interval has passed. 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, and two comptime capability flags say so once each. It exposes no `Panel*` transition builtins (`PanelSlide`, `PanelZoom`, `PanelDissolve`, `PanelAscii`, `PanelVertical`, `PanelEdges`, `PanelFall`, `PanelWave`, `PanelCurtain`, `PanelScramble`, `PanelType`) because `capabilities.panel_transitions` is `pardes.hosted`, and no `Crt` or `Shader` because `capabilities.scene_shaders` is `platform == .gui or platform == .macos` (`builtins.capabilities`; the words themselves carry those availabilities in `config.Runtime.settings`). Applying either 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 zig build web \ -Dplatform=web \ -Dtarget=wasm32-freestanding \ -Dtree-sitter=zig \ -Ddump=test/web-snapshots/touch.dump.zon ``` The output is in `zig-out/web`. Serve that directory over HTTP; browsers do not allow a useful WASM module load from `file:` URLs. The browser shell has no argv: every command-line flag `src/main.zig` parses — `--tty`, `--tty-toggle`, `-n`, `-l`, `--9p=`, `--mount==`, `--nested`, `--detach[=]`, `--attach[=]`, `-h`/`--help`, `--version`, and the positional 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, and there is nothing here to answer `--version` with even though `pardes.version` and `pardes.commit` are compiled in like everywhere else. 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 directory, init file, ThemeFile, or DumpThemes; 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, and the host leaves `lsp` null). 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. `Attach` and `Detach` are absent for the same structural reason and behind the same gate: both are `enabled = pardes.hosted` (`src/builtins.zig`, and `pardes.hosted` is `tty or gui or macos`), because a detached session is a unix socket and a page has none. Nothing in this shell speaks `src/detached/wire.zig`, and `detach` is one of the null vtable methods below. `src/web.zig` fills in six methods of `Host.VTable` and leaves the rest null, and a null method is answered by the core itself rather than forwarded to a page that could not honour it. The six are `present`, `write_file`, `write_dump`, `set_clipboard`, `read_clipboard` and `open_link`; the last five are backed by four imports from the module `pardes` that JavaScript supplies at instantiation, because a written file and a written dump are both a download: | import | effect behind it | |---|---| | `host_open_link(url, len)` | `open_link` — `window.open` | | `host_set_clipboard(text, len)` | `set_clipboard` — `navigator.clipboard.writeText` | | `host_read_clipboard()` | `read_clipboard` — answered later, or never, by `pardes_paste` | | `host_download(path, path_len, bytes, len)` | `save_file`, `save_text` and `write_dump`: a file a page writes is a download | What the core answers instead of asking: `spawn`, `pty_write` and `pty_resize` land in the per-instance fallback, so a pane with no child is SILENT rather than synthetic; `tty_taken` is never taken; any save the page declines resolves against the core's virtual filesystem, which is also where every read comes from — the core reads files itself and never asks a host; `watch_file` records the request; `lsp` answers an empty row set at once (`zls_backend` is off for wasm, so the core never raises a query there anyway) and `pipe` answers failure; `wait_input` is null because wasm must never block, and `watch_theme` and `dump_themes` are not reachable without a config directory. Of the remaining nulls, `post_present` and `poll_frame` are per-frame host bookkeeping a page has none of, `detach` has no session to leave, `gpio_toggle` belongs to the ESP32-P4 board, and native filesystem replies are delivered by the 9P listener. `quit` sets the core's flag, which `pardes_should_quit` reports so the page can stop its frame loop. Two dependency-free test layers cover the boundary: ```sh # Real WASM in Node plus a small DOM double. zig build web-harness \ -Dplatform=web \ -Dtarget=wasm32-freestanding \ -Dtree-sitter=zig \ -Ddump=test/web-snapshots/touch.dump.zon # Real headless Chrome, DOM rendering, and browser touch input. zig build web-e2e ``` Set `PARDES_CHROME` when Chrome/Chromium is not on `PATH`, in a normal Playwright/Puppeteer cache, or — on macOS, where a browser's executable lives inside an application bundle and never on `PATH` — under `/Applications`.