summaryrefslogtreecommitdiff
path: root/src/web
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-01 22:44:19 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commitd59397d4146ffa23adbbe6dc99c64ebb22a93b2d (patch)
treea06984bf172a6c894c1586d58aad02ec302b6c2d /src/web
parentcbcdf01764e2e144a821643c3a8e9a5ad39eda2e (diff)
downloadpardes-d59397d4146ffa23adbbe6dc99c64ebb22a93b2d.tar.gz
pardes-d59397d4146ffa23adbbe6dc99c64ebb22a93b2d.zip
add vanilla DOM web backend
Diffstat (limited to 'src/web')
-rw-r--r--src/web/app.mjs436
-rw-r--r--src/web/index.html17
-rw-r--r--src/web/libc.c152
-rw-r--r--src/web/libc/include/assert.h14
-rw-r--r--src/web/libc/include/ctype.h7
-rw-r--r--src/web/libc/include/endian.h21
-rw-r--r--src/web/libc/include/inttypes.h8
-rw-r--r--src/web/libc/include/limits.h9
-rw-r--r--src/web/libc/include/stdio.h19
-rw-r--r--src/web/libc/include/stdlib.h16
-rw-r--r--src/web/libc/include/string.h17
-rw-r--r--src/web/libc/include/wchar.h9
-rw-r--r--src/web/libc/include/wctype.h18
-rw-r--r--src/web/pardes.css91
-rw-r--r--src/web/zstbi.zig30
15 files changed, 864 insertions, 0 deletions
diff --git a/src/web/app.mjs b/src/web/app.mjs
new file mode 100644
index 00000000..3d32a16b
--- /dev/null
+++ b/src/web/app.mjs
@@ -0,0 +1,436 @@
+const CELL_SIZE = 20;
+const DEFAULT_FG = "#cccccc";
+const DEFAULT_BG = "#121212";
+const decoder = new TextDecoder();
+const encoder = new TextEncoder();
+
+const BUTTON = Object.freeze({ left: 0, middle: 1, right: 2, wheelUp: 3, wheelDown: 4, wheelLeft: 5, wheelRight: 6, none: 7 });
+const MOUSE = Object.freeze({ press: 0, release: 1, motion: 2, drag: 3 });
+const SPECIAL_KEYS = Object.freeze({
+ Enter: 0x0d,
+ Backspace: 0x7f,
+ Tab: 0x09,
+ Escape: 0x1b,
+ ArrowUp: 0xf0001,
+ ArrowDown: 0xf0002,
+ ArrowLeft: 0xf0003,
+ ArrowRight: 0xf0004,
+ Home: 0xf0005,
+ End: 0xf0006,
+ PageUp: 0xf0007,
+ PageDown: 0xf0008,
+ Delete: 0xf0009,
+});
+
+export class DomRenderer {
+ constructor(root, wasm) {
+ this.root = root;
+ this.wasm = wasm;
+ this.rows = [];
+ this.cells = [];
+ this.cols = 0;
+ this.rowCount = 0;
+ this.snapshot = null;
+ }
+
+ render() {
+ const count = this.wasm.pardes_frame();
+ const cols = this.wasm.pardes_frame_cols();
+ const rows = this.wasm.pardes_frame_rows();
+ if (count !== cols * rows) throw new Error(`bad frame: ${count} cells for ${cols}x${rows}`);
+ this.ensureGrid(cols, rows);
+
+ const ptr = this.wasm.pardes_frame_ptr();
+ const view = new DataView(this.wasm.memory.buffer, ptr, count * CELL_SIZE);
+ const bytes = new Uint8Array(this.wasm.memory.buffer);
+ const cursorX = this.wasm.pardes_cursor_x();
+ const cursorY = this.wasm.pardes_cursor_y();
+ const cursorBar = this.wasm.pardes_cursor_bar() !== 0;
+ const raw = new Array(count);
+
+ for (let index = 0; index < count; index += 1) {
+ const off = index * CELL_SIZE;
+ const len = view.getUint8(off + 18);
+ const fg = view.getUint32(off + 8, true);
+ const bg = view.getUint32(off + 12, true);
+ const attrs = view.getUint16(off + 16, true);
+ const text = decoder.decode(bytes.subarray(ptr + off, ptr + off + len)) || " ";
+ const x = index % cols;
+ const y = Math.floor(index / cols);
+ const cursor = x === cursorX && y === cursorY;
+ const cell = this.cells[index];
+ if (cell.textContent !== text) cell.textContent = text;
+ const styleKey = `${fg}:${bg}:${attrs}:${cursor ? 1 : 0}:${cursorBar ? 1 : 0}`;
+ if (cell.pardesStyle !== styleKey) {
+ applyStyle(cell, fg, bg, attrs, cursor, cursorBar);
+ cell.pardesStyle = styleKey;
+ }
+ raw[index] = { text, fg, bg, attrs };
+ }
+ this.snapshot = { cols, rows, cells: raw, cursorX, cursorY, cursorBar };
+ return this.snapshot;
+ }
+
+ ensureGrid(cols, rows) {
+ if (cols === this.cols && rows === this.rowCount) return;
+ const document = this.root.ownerDocument;
+ const rowNodes = [];
+ const cells = [];
+ for (let y = 0; y < rows; y += 1) {
+ const row = document.createElement("div");
+ row.className = "pardes-row";
+ row.setAttribute("role", "row");
+ row.dataset.row = String(y);
+ const rowCells = [];
+ for (let x = 0; x < cols; x += 1) {
+ const cell = document.createElement("span");
+ cell.className = "pardes-cell";
+ cell.setAttribute("role", "gridcell");
+ cell.dataset.col = String(x);
+ cell.dataset.row = String(y);
+ cell.textContent = " ";
+ rowCells.push(cell);
+ cells.push(cell);
+ }
+ row.replaceChildren(...rowCells);
+ rowNodes.push(row);
+ }
+ this.root.replaceChildren(...rowNodes);
+ this.root.setAttribute("aria-colcount", String(cols));
+ this.root.setAttribute("aria-rowcount", String(rows));
+ this.rows = rowNodes;
+ this.cells = cells;
+ this.cols = cols;
+ this.rowCount = rows;
+ }
+}
+
+export class PardesRuntime {
+ constructor(root, wasm) {
+ this.root = root;
+ this.wasm = wasm;
+ this.renderer = new DomRenderer(root, wasm);
+ this.inputPtr = wasm.pardes_input_ptr();
+ this.inputCapacity = wasm.pardes_input_capacity();
+ this.cellWidth = 1;
+ this.cellHeight = 1;
+ this.running = false;
+ this.pointer = null;
+ this.frameHandle = 0;
+ this.bindEvents();
+ }
+
+ start() {
+ this.measure();
+ const { cols, rows } = this.gridSize();
+ const error = this.wasm.pardes_init(cols, rows);
+ if (error) throw new Error(`Pardes wasm init failed (${error})`);
+ this.inputPtr = this.wasm.pardes_input_ptr();
+ this.resize();
+ this.renderer.render();
+ this.drainEffects();
+ this.running = true;
+ this.frameHandle = requestAnimationFrame(() => this.frame());
+ this.root.focus({ preventScroll: true });
+ }
+
+ stop() {
+ this.running = false;
+ if (this.frameHandle) cancelAnimationFrame(this.frameHandle);
+ this.wasm.pardes_deinit();
+ }
+
+ frame() {
+ if (!this.running) return;
+ this.wasm.pardes_tick();
+ this.renderer.render();
+ this.drainEffects();
+ this.frameHandle = requestAnimationFrame(() => this.frame());
+ }
+
+ measure() {
+ const probe = this.root.ownerDocument.createElement("span");
+ probe.className = "pardes-probe";
+ probe.textContent = "M";
+ this.root.ownerDocument.body.append(probe);
+ const rect = probe.getBoundingClientRect();
+ probe.remove();
+ this.cellWidth = Math.max(1, rect.width);
+ this.cellHeight = Math.max(1, rect.height);
+ }
+
+ gridSize() {
+ return {
+ cols: Math.max(1, Math.floor(this.root.clientWidth / this.cellWidth)),
+ rows: Math.max(1, Math.floor(this.root.clientHeight / this.cellHeight)),
+ };
+ }
+
+ resize() {
+ this.measure();
+ const { cols, rows } = this.gridSize();
+ this.wasm.pardes_resize(cols, rows);
+ }
+
+ writeInput(text) {
+ const data = encoder.encode(text);
+ if (data.length > this.inputCapacity) throw new Error("browser input exceeds wasm transfer buffer");
+ new Uint8Array(this.wasm.memory.buffer, this.inputPtr, data.length).set(data);
+ return data.length;
+ }
+
+ key(event) {
+ const mods = modifiers(event);
+ let cp = SPECIAL_KEYS[event.key];
+ let text = "";
+ if (cp === undefined && event.key.length > 0 && [...event.key].length === 1) {
+ text = mods & 3 ? "" : event.key;
+ const shortcut = event.code?.match(/^Key([A-Z])$/);
+ cp = shortcut && (mods & 3) ? shortcut[1].toLowerCase().codePointAt(0) : event.key.codePointAt(0);
+ }
+ if (cp === undefined) return;
+ event.preventDefault();
+ this.wasm.pardes_key(cp, this.writeInput(text), mods);
+ }
+
+ point(event) {
+ const rect = this.root.getBoundingClientRect();
+ return {
+ col: clamp(Math.floor((event.clientX - rect.left) / this.cellWidth), 0, Math.max(0, this.wasm.pardes_frame_cols() - 1)),
+ row: clamp(Math.floor((event.clientY - rect.top) / this.cellHeight), 0, Math.max(0, this.wasm.pardes_frame_rows() - 1)),
+ };
+ }
+
+ cellPoint(col, row) {
+ const rect = this.root.getBoundingClientRect();
+ return { x: rect.left + (col + 0.5) * this.cellWidth, y: rect.top + (row + 0.5) * this.cellHeight };
+ }
+
+ pointerDown(event) {
+ event.preventDefault();
+ this.root.focus({ preventScroll: true });
+ this.root.setPointerCapture?.(event.pointerId);
+ const point = this.point(event);
+ if (event.pointerType === "touch") {
+ const packed = this.wasm.pardes_chrome_target(point.col, point.row) >>> 0;
+ const chrome = packed !== 0xffffffff;
+ const snapped = chrome ? { col: packed & 0xffff, row: packed >>> 16 } : point;
+ this.pointer = { id: event.pointerId, touch: true, chrome, startX: event.clientX, startY: event.clientY, lastX: event.clientX, lastY: event.clientY, point: snapped, scrolling: false, accum: 0 };
+ if (chrome) this.wasm.pardes_mouse(BUTTON.left, MOUSE.press, snapped.col, snapped.row, modifiers(event));
+ return;
+ }
+ const button = pointerButton(event.button);
+ this.pointer = { id: event.pointerId, touch: false, button };
+ this.wasm.pardes_mouse(button, MOUSE.press, point.col, point.row, modifiers(event));
+ }
+
+ pointerMove(event) {
+ const point = this.point(event);
+ const active = this.pointer?.id === event.pointerId ? this.pointer : null;
+ if (!active) {
+ if (event.pointerType !== "touch") this.wasm.pardes_mouse(BUTTON.none, MOUSE.motion, point.col, point.row, modifiers(event));
+ return;
+ }
+ event.preventDefault();
+ if (!active.touch) {
+ this.wasm.pardes_mouse(active.button, MOUSE.drag, point.col, point.row, modifiers(event));
+ return;
+ }
+ if (active.chrome) {
+ this.wasm.pardes_mouse(BUTTON.left, MOUSE.drag, point.col, point.row, modifiers(event));
+ return;
+ }
+ const travel = Math.hypot(event.clientX - active.startX, event.clientY - active.startY);
+ if (travel > 8) active.scrolling = true;
+ if (active.scrolling) {
+ const dy = event.clientY - active.lastY;
+ active.accum += dy / this.cellHeight;
+ while (active.accum <= -1) {
+ this.wasm.pardes_mouse(BUTTON.wheelDown, MOUSE.press, point.col, point.row, 0);
+ active.accum += 1;
+ }
+ while (active.accum >= 1) {
+ this.wasm.pardes_mouse(BUTTON.wheelUp, MOUSE.press, point.col, point.row, 0);
+ active.accum -= 1;
+ }
+ this.wasm.pardes_touch_scroll(dy / this.cellHeight);
+ }
+ active.lastX = event.clientX;
+ active.lastY = event.clientY;
+ }
+
+ pointerUp(event) {
+ const active = this.pointer?.id === event.pointerId ? this.pointer : null;
+ if (!active) return;
+ event.preventDefault();
+ const point = this.point(event);
+ if (active.touch) {
+ if (active.chrome) this.wasm.pardes_mouse(BUTTON.left, MOUSE.release, point.col, point.row, modifiers(event));
+ else if (!active.scrolling) {
+ this.wasm.pardes_mouse(BUTTON.right, MOUSE.press, point.col, point.row, 0);
+ this.wasm.pardes_mouse(BUTTON.right, MOUSE.release, point.col, point.row, 0);
+ }
+ } else {
+ this.wasm.pardes_mouse(active.button, MOUSE.release, point.col, point.row, modifiers(event));
+ }
+ if (this.root.hasPointerCapture?.(event.pointerId)) this.root.releasePointerCapture(event.pointerId);
+ this.pointer = null;
+ }
+
+ wheel(event) {
+ event.preventDefault();
+ const point = this.point(event);
+ if (event.deltaY) this.wasm.pardes_mouse(event.deltaY < 0 ? BUTTON.wheelUp : BUTTON.wheelDown, MOUSE.press, point.col, point.row, modifiers(event));
+ if (event.deltaX) this.wasm.pardes_mouse(event.deltaX < 0 ? BUTTON.wheelLeft : BUTTON.wheelRight, MOUSE.press, point.col, point.row, modifiers(event));
+ }
+
+ paste(event) {
+ const text = event.clipboardData?.getData("text/plain");
+ if (text === undefined) return;
+ event.preventDefault();
+ this.wasm.pardes_paste(this.writeInput(text));
+ }
+
+ bindEvents() {
+ this.root.addEventListener("keydown", (event) => this.key(event));
+ this.root.addEventListener("paste", (event) => this.paste(event));
+ this.root.addEventListener("contextmenu", (event) => event.preventDefault());
+ this.root.addEventListener("pointerdown", (event) => this.pointerDown(event));
+ this.root.addEventListener("pointermove", (event) => this.pointerMove(event));
+ this.root.addEventListener("pointerup", (event) => this.pointerUp(event));
+ this.root.addEventListener("pointercancel", (event) => this.pointerUp(event));
+ this.root.addEventListener("wheel", (event) => this.wheel(event), { passive: false });
+ globalThis.addEventListener?.("resize", () => this.resize());
+ }
+
+ drainEffects() {
+ for (;;) {
+ const kind = this.wasm.pardes_effect_next();
+ if (!kind) return;
+ const ptr = this.wasm.pardes_effect_ptr();
+ const len = this.wasm.pardes_effect_len();
+ const data = new Uint8Array(len);
+ if (len) data.set(new Uint8Array(this.wasm.memory.buffer, ptr, len));
+ const aux0 = this.wasm.pardes_effect_aux0();
+ const aux1 = this.wasm.pardes_effect_aux1();
+ this.handleEffect(kind, data, aux0, aux1);
+ }
+ }
+
+ handleEffect(kind, data, aux0, aux1) {
+ if (kind === 4) {
+ globalThis.open?.(decoder.decode(data), "_blank", "noopener,noreferrer");
+ } else if (kind === 5) {
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
+ const pathLength = data.length >= 4 ? view.getUint32(0, true) : 0;
+ const path = decoder.decode(data.subarray(4, 4 + pathLength));
+ download(data.subarray(4 + pathLength), path.split("/").pop() || "pardes.txt", "text/plain;charset=utf-8");
+ } else if (kind === 6) {
+ download(data, "pardes-dump.zon", "text/plain;charset=utf-8");
+ } else if (kind === 7) {
+ navigator.clipboard?.writeText(decoder.decode(data)).catch(() => {});
+ } else if (kind === 10) {
+ this.running = false;
+ } else {
+ this.root.dispatchEvent(new CustomEvent("pardes-io", { detail: { kind, data, aux0, aux1 } }));
+ }
+ }
+}
+
+export async function instantiate(url = "./pardes.wasm") {
+ const response = await fetch(url);
+ if (!response.ok) throw new Error(`fetch ${url}: HTTP ${response.status}`);
+ if (WebAssembly.instantiateStreaming) {
+ try {
+ return (await WebAssembly.instantiateStreaming(response.clone(), {})).instance.exports;
+ } catch (error) {
+ if (!/mime|content-type/i.test(String(error))) throw error;
+ }
+ }
+ return (await WebAssembly.instantiate(await response.arrayBuffer(), {})).instance.exports;
+}
+
+export async function boot(root, url = "./pardes.wasm") {
+ if (!root) throw new Error("Pardes DOM root is missing");
+ const wasm = await instantiate(url);
+ const runtime = new PardesRuntime(root, wasm);
+ runtime.start();
+ const test = {
+ grid: () => runtime.renderer.snapshot,
+ cellPoint: (col, row) => runtime.cellPoint(col, row),
+ };
+ globalThis.pardes = { runtime, wasm, test };
+ return runtime;
+}
+
+function applyStyle(cell, fgCode, bgCode, attrs, cursor, cursorBar) {
+ let fg = color(fgCode, DEFAULT_FG);
+ let bg = color(bgCode, DEFAULT_BG);
+ if (attrs & (1 << 4)) [fg, bg] = [bg, fg];
+ if (attrs & (1 << 5)) fg = bg;
+ if (attrs & (1 << 1)) fg = dim(fg);
+ cell.style.color = fg;
+ cell.style.backgroundColor = bg;
+ cell.style.fontWeight = attrs & 1 ? "700" : "400";
+ cell.style.fontStyle = attrs & (1 << 2) ? "italic" : "normal";
+ const underline = (attrs >>> 8) & 7;
+ cell.style.textDecorationLine = `${underline ? "underline " : ""}${attrs & (1 << 6) ? "line-through" : ""}`.trim() || "none";
+ cell.style.textDecorationStyle = ["solid", "solid", "double", "wavy", "dotted", "dashed"][underline] || "solid";
+ cell.style.setProperty("--cell-fg", fg);
+ cell.style.setProperty("--cell-bg", bg);
+ cell.classList.toggle("is-cursor", cursor);
+ cell.classList.toggle("is-bar", cursor && cursorBar);
+ cell.classList.toggle("is-blinking", Boolean(attrs & (1 << 3)));
+}
+
+function color(encoded, fallback) {
+ if (encoded === 0x01000000) return fallback;
+ if ((encoded & 0xff000000) === 0x02000000) return indexedColor(encoded & 0xff);
+ return `#${(encoded & 0xffffff).toString(16).padStart(6, "0")}`;
+}
+
+function indexedColor(index) {
+ const ansi = [
+ "#000000", "#cc0000", "#4e9a06", "#c4a000", "#3465a4", "#75507b", "#06989a", "#d3d7cf",
+ "#555753", "#ef2929", "#8ae234", "#fce94f", "#729fcf", "#ad7fa8", "#34e2e2", "#eeeeec",
+ ];
+ if (index < 16) return ansi[index];
+ if (index >= 232) {
+ const level = 8 + (index - 232) * 10;
+ return rgb(level, level, level);
+ }
+ const n = index - 16;
+ const level = (part) => part === 0 ? 0 : 55 + part * 40;
+ return rgb(level(Math.floor(n / 36)), level(Math.floor(n / 6) % 6), level(n % 6));
+}
+
+function dim(value) {
+ const n = Number.parseInt(value.slice(1), 16);
+ return rgb(((n >>> 16) & 0xff) * 0.6, ((n >>> 8) & 0xff) * 0.6, (n & 0xff) * 0.6);
+}
+
+function rgb(r, g, b) {
+ return `#${[r, g, b].map((part) => Math.round(part).toString(16).padStart(2, "0")).join("")}`;
+}
+
+function modifiers(event) {
+ return (event.ctrlKey ? 1 : 0) | (event.altKey ? 2 : 0) | (event.shiftKey ? 4 : 0);
+}
+
+function pointerButton(button) {
+ return button === 1 ? BUTTON.middle : button === 2 ? BUTTON.right : BUTTON.left;
+}
+
+function clamp(value, low, high) {
+ return Math.max(low, Math.min(high, value));
+}
+
+function download(bytes, name, type) {
+ if (typeof document === "undefined") return;
+ const url = URL.createObjectURL(new Blob([bytes], { type }));
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = name;
+ anchor.click();
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+}
diff --git a/src/web/index.html b/src/web/index.html
new file mode 100644
index 00000000..4012c313
--- /dev/null
+++ b/src/web/index.html
@@ -0,0 +1,17 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Pardes</title>
+ <link rel="stylesheet" href="pardes.css">
+</head>
+<body>
+ <main id="pardes" class="pardes" role="grid" aria-label="Pardes text environment" tabindex="0"></main>
+ <noscript>Pardes needs JavaScript to drive its WebAssembly core.</noscript>
+ <script type="module">
+ import { boot } from "./app.mjs";
+ boot(document.getElementById("pardes"));
+ </script>
+</body>
+</html>
diff --git a/src/web/libc.c b/src/web/libc.c
new file mode 100644
index 00000000..c49fa03e
--- /dev/null
+++ b/src/web/libc.c
@@ -0,0 +1,152 @@
+/* Minimal libc-shaped surface used by Tree-sitter inside the freestanding
+ * browser module. Allocation lives in web.zig so it shares Zig's wasm heap;
+ * these routines neither import browser functions nor perform IO. */
+#include <ctype.h>
+#include <stdarg.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <string.h>
+#include <wctype.h>
+
+_Noreturn void abort(void) {
+ __builtin_trap();
+}
+
+size_t strlen(const char *str) {
+ const char *end = str;
+ while (*end) end++;
+ return (size_t)(end - str);
+}
+
+char *strchr(const char *str, int character) {
+ const unsigned char needle = (unsigned char)character;
+ do {
+ if ((unsigned char)*str == needle) return (char *)str;
+ } while (*str++);
+ return (char *)0;
+}
+
+void *memchr(const void *ptr, int value, size_t count) {
+ const unsigned char *bytes = (const unsigned char *)ptr;
+ const unsigned char needle = (unsigned char)value;
+ for (size_t index = 0; index < count; index++) {
+ if (bytes[index] == needle) return (void *)(bytes + index);
+ }
+ return (void *)0;
+}
+
+int strcmp(const char *lhs, const char *rhs) {
+ while (*lhs && (unsigned char)*lhs == (unsigned char)*rhs) {
+ lhs++;
+ rhs++;
+ }
+ return (int)(unsigned char)*lhs - (int)(unsigned char)*rhs;
+}
+
+int strncmp(const char *lhs, const char *rhs, size_t count) {
+ while (count && *lhs && (unsigned char)*lhs == (unsigned char)*rhs) {
+ lhs++;
+ rhs++;
+ count--;
+ }
+ return count ? (int)(unsigned char)*lhs - (int)(unsigned char)*rhs : 0;
+}
+
+char *strncpy(char *restrict dest, const char *restrict src, size_t count) {
+ char *result = dest;
+ while (count && *src) {
+ *dest++ = *src++;
+ count--;
+ }
+ while (count--) *dest++ = '\0';
+ return result;
+}
+
+int isprint(int character) {
+ return character >= 0x20 && character <= 0x7e;
+}
+
+int isdigit(int character) {
+ return character >= '0' && character <= '9';
+}
+
+int iswspace(wint_t character) {
+ return character == ' ' || (character >= '\t' && character <= '\r');
+}
+
+int iswalnum(wint_t character) {
+ return iswdigit(character) || iswalpha(character);
+}
+
+int iswalpha(wint_t character) {
+ return (character >= 'A' && character <= 'Z') ||
+ (character >= 'a' && character <= 'z') || character >= 0x80;
+}
+
+int iswblank(wint_t character) {
+ return character == ' ' || character == '\t';
+}
+
+int iswdigit(wint_t character) {
+ return character >= '0' && character <= '9';
+}
+
+int iswlower(wint_t character) {
+ return character >= 'a' && character <= 'z';
+}
+
+int iswupper(wint_t character) {
+ return character >= 'A' && character <= 'Z';
+}
+
+int iswxdigit(wint_t character) {
+ return iswdigit(character) || (character >= 'A' && character <= 'F') ||
+ (character >= 'a' && character <= 'f');
+}
+
+wint_t towupper(wint_t character) {
+ return iswlower(character) ? character - ('a' - 'A') : character;
+}
+
+int vsnprintf(char *restrict buffer, size_t size, const char *restrict format, va_list args) {
+ (void)format;
+ (void)args;
+ if (size) buffer[0] = '\0';
+ return 0;
+}
+
+int snprintf(char *restrict buffer, size_t size, const char *restrict format, ...) {
+ va_list args;
+ va_start(args, format);
+ int result = vsnprintf(buffer, size, format, args);
+ va_end(args);
+ return result;
+}
+
+int fprintf(FILE *stream, const char *format, ...) {
+ (void)stream;
+ (void)format;
+ return 0;
+}
+
+int fclose(FILE *stream) {
+ (void)stream;
+ return 0;
+}
+
+FILE *fdopen(int descriptor, const char *mode) {
+ (void)descriptor;
+ (void)mode;
+ return (FILE *)0;
+}
+
+int fputc(int character, FILE *stream) {
+ (void)stream;
+ return character;
+}
+
+int fputs(const char *string, FILE *stream) {
+ (void)stream;
+ return (int)strlen(string);
+}
diff --git a/src/web/libc/include/assert.h b/src/web/libc/include/assert.h
new file mode 100644
index 00000000..fd018947
--- /dev/null
+++ b/src/web/libc/include/assert.h
@@ -0,0 +1,14 @@
+#ifndef PARDES_WEB_ASSERT_H
+#define PARDES_WEB_ASSERT_H
+
+#ifndef static_assert
+#define static_assert _Static_assert
+#endif
+
+#ifdef NDEBUG
+#define assert(expression) ((void)0)
+#else
+#define assert(expression) ((expression) ? (void)0 : __builtin_trap())
+#endif
+
+#endif
diff --git a/src/web/libc/include/ctype.h b/src/web/libc/include/ctype.h
new file mode 100644
index 00000000..985c8759
--- /dev/null
+++ b/src/web/libc/include/ctype.h
@@ -0,0 +1,7 @@
+#ifndef PARDES_WEB_CTYPE_H
+#define PARDES_WEB_CTYPE_H
+
+int isprint(int character);
+int isdigit(int character);
+
+#endif
diff --git a/src/web/libc/include/endian.h b/src/web/libc/include/endian.h
new file mode 100644
index 00000000..a047c88f
--- /dev/null
+++ b/src/web/libc/include/endian.h
@@ -0,0 +1,21 @@
+#ifndef PARDES_WEB_ENDIAN_H
+#define PARDES_WEB_ENDIAN_H
+
+#define __LITTLE_ENDIAN 1234
+#define __BIG_ENDIAN 4321
+#define __BYTE_ORDER __LITTLE_ENDIAN
+
+#define htole16(value) (value)
+#define le16toh(value) (value)
+#define htole32(value) (value)
+#define le32toh(value) (value)
+#define htole64(value) (value)
+#define le64toh(value) (value)
+#define htobe16(value) __builtin_bswap16(value)
+#define be16toh(value) __builtin_bswap16(value)
+#define htobe32(value) __builtin_bswap32(value)
+#define be32toh(value) __builtin_bswap32(value)
+#define htobe64(value) __builtin_bswap64(value)
+#define be64toh(value) __builtin_bswap64(value)
+
+#endif
diff --git a/src/web/libc/include/inttypes.h b/src/web/libc/include/inttypes.h
new file mode 100644
index 00000000..7e795746
--- /dev/null
+++ b/src/web/libc/include/inttypes.h
@@ -0,0 +1,8 @@
+#ifndef PARDES_WEB_INTTYPES_H
+#define PARDES_WEB_INTTYPES_H
+
+#include <stdint.h>
+
+#define PRId32 "d"
+
+#endif
diff --git a/src/web/libc/include/limits.h b/src/web/libc/include/limits.h
new file mode 100644
index 00000000..6ba31375
--- /dev/null
+++ b/src/web/libc/include/limits.h
@@ -0,0 +1,9 @@
+#ifndef PARDES_WEB_LIMITS_H
+#define PARDES_WEB_LIMITS_H
+
+#define CHAR_BIT __CHAR_BIT__
+#define INT_MAX __INT_MAX__
+#define UINT_MAX (__INT_MAX__ * 2U + 1U)
+#define USHRT_MAX (__SHRT_MAX__ * 2U + 1U)
+
+#endif
diff --git a/src/web/libc/include/stdio.h b/src/web/libc/include/stdio.h
new file mode 100644
index 00000000..92895054
--- /dev/null
+++ b/src/web/libc/include/stdio.h
@@ -0,0 +1,19 @@
+#ifndef PARDES_WEB_STDIO_H
+#define PARDES_WEB_STDIO_H
+
+#include <stdarg.h>
+#include <stddef.h>
+
+typedef struct PardesWebFile FILE;
+
+#define stderr ((FILE *)0)
+
+int fprintf(FILE *stream, const char *format, ...);
+int fclose(FILE *stream);
+FILE *fdopen(int descriptor, const char *mode);
+int fputc(int character, FILE *stream);
+int fputs(const char *string, FILE *stream);
+int snprintf(char *restrict buffer, size_t size, const char *restrict format, ...);
+int vsnprintf(char *restrict buffer, size_t size, const char *restrict format, va_list args);
+
+#endif
diff --git a/src/web/libc/include/stdlib.h b/src/web/libc/include/stdlib.h
new file mode 100644
index 00000000..cb280904
--- /dev/null
+++ b/src/web/libc/include/stdlib.h
@@ -0,0 +1,16 @@
+#ifndef PARDES_WEB_STDLIB_H
+#define PARDES_WEB_STDLIB_H
+
+#include <stddef.h>
+
+#ifndef NULL
+#define NULL ((void *)0)
+#endif
+
+void *malloc(size_t size);
+void *calloc(size_t count, size_t size);
+void *realloc(void *ptr, size_t size);
+void free(void *ptr);
+_Noreturn void abort(void);
+
+#endif
diff --git a/src/web/libc/include/string.h b/src/web/libc/include/string.h
new file mode 100644
index 00000000..7e5b8783
--- /dev/null
+++ b/src/web/libc/include/string.h
@@ -0,0 +1,17 @@
+#ifndef PARDES_WEB_STRING_H
+#define PARDES_WEB_STRING_H
+
+#include <stddef.h>
+
+void *memchr(const void *ptr, int value, size_t count);
+int memcmp(const void *lhs, const void *rhs, size_t count);
+void *memcpy(void *restrict dest, const void *restrict src, size_t count);
+void *memmove(void *dest, const void *src, size_t count);
+void *memset(void *dest, int value, size_t count);
+size_t strlen(const char *str);
+char *strchr(const char *str, int character);
+int strcmp(const char *lhs, const char *rhs);
+int strncmp(const char *lhs, const char *rhs, size_t count);
+char *strncpy(char *restrict dest, const char *restrict src, size_t count);
+
+#endif
diff --git a/src/web/libc/include/wchar.h b/src/web/libc/include/wchar.h
new file mode 100644
index 00000000..4888631e
--- /dev/null
+++ b/src/web/libc/include/wchar.h
@@ -0,0 +1,9 @@
+#ifndef PARDES_WEB_WCHAR_H
+#define PARDES_WEB_WCHAR_H
+
+#include <stddef.h>
+#include <wctype.h>
+
+typedef __WCHAR_TYPE__ wchar_t;
+
+#endif
diff --git a/src/web/libc/include/wctype.h b/src/web/libc/include/wctype.h
new file mode 100644
index 00000000..77bbabe9
--- /dev/null
+++ b/src/web/libc/include/wctype.h
@@ -0,0 +1,18 @@
+#ifndef PARDES_WEB_WCTYPE_H
+#define PARDES_WEB_WCTYPE_H
+
+#include <stddef.h>
+
+typedef unsigned int wint_t;
+
+int iswalnum(wint_t character);
+int iswalpha(wint_t character);
+int iswblank(wint_t character);
+int iswdigit(wint_t character);
+int iswlower(wint_t character);
+int iswspace(wint_t character);
+int iswupper(wint_t character);
+int iswxdigit(wint_t character);
+wint_t towupper(wint_t character);
+
+#endif
diff --git a/src/web/pardes.css b/src/web/pardes.css
new file mode 100644
index 00000000..a959e6c3
--- /dev/null
+++ b/src/web/pardes.css
@@ -0,0 +1,91 @@
+@font-face {
+ font-family: "Pardes Mono";
+ src: url("./AdwaitaMono-Regular.ttf") format("truetype");
+ font-display: swap;
+}
+
+:root {
+ color-scheme: dark;
+ --pardes-bg: #121212;
+ --pardes-fg: #cccccc;
+ --pardes-font-size: 18px;
+ --pardes-cell-width: 9.333333px;
+ --pardes-cell-height: 19.459459px;
+}
+
+html,
+body {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+ background: var(--pardes-bg);
+}
+
+.pardes {
+ box-sizing: border-box;
+ width: 100vw;
+ height: 100vh;
+ overflow: hidden;
+ color: var(--pardes-fg);
+ background: var(--pardes-bg);
+ font-family: "Pardes Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: var(--pardes-font-size);
+ font-variant-ligatures: none;
+ line-height: 1;
+ outline: none;
+ touch-action: none;
+ user-select: text;
+ cursor: text;
+}
+
+.pardes-row {
+ display: flex;
+ width: max-content;
+ height: var(--pardes-cell-height);
+ line-height: var(--pardes-cell-height);
+ white-space: pre;
+}
+
+.pardes-cell {
+ box-sizing: border-box;
+ display: inline-block;
+ flex: 0 0 var(--pardes-cell-width);
+ width: var(--pardes-cell-width);
+ height: var(--pardes-cell-height);
+ overflow: hidden;
+ text-align: left;
+ white-space: pre;
+}
+
+.pardes-cell.is-cursor {
+ color: var(--cell-bg) !important;
+ background: var(--cell-fg) !important;
+}
+
+.pardes-cell.is-cursor.is-bar {
+ color: var(--cell-fg) !important;
+ background: var(--cell-bg) !important;
+ box-shadow: inset 2px 0 var(--cell-fg);
+}
+
+.pardes-cell.is-blinking {
+ animation: pardes-blink 1s step-end infinite;
+}
+
+.pardes-probe {
+ position: fixed;
+ left: -10000px;
+ top: -10000px;
+ width: var(--pardes-cell-width);
+ height: var(--pardes-cell-height);
+ visibility: hidden;
+}
+
+@keyframes pardes-blink {
+ 50% { opacity: 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .pardes-cell.is-blinking { animation: none; }
+}
diff --git a/src/web/zstbi.zig b/src/web/zstbi.zig
new file mode 100644
index 00000000..76b144f7
--- /dev/null
+++ b/src/web/zstbi.zig
@@ -0,0 +1,30 @@
+//! Freestanding image-decoder shim.
+//!
+//! The browser core has no libc or C allocator ABI. Image bytes still survive
+//! dumps, but decoding belongs in the JavaScript IO layer rather than pulling
+//! stb_image and an Emscripten runtime into the state machine.
+
+const std = @import("std");
+
+pub fn init(_: std.Io, _: std.mem.Allocator) void {}
+pub fn deinit() void {}
+
+pub const Image = struct {
+ data: []u8 = &.{},
+ width: u32 = 0,
+ height: u32 = 0,
+ num_components: u32 = 4,
+ bytes_per_component: u32 = 1,
+ bytes_per_row: u32 = 0,
+ is_hdr: bool = false,
+
+ pub fn loadFromMemory(_: []const u8, _: u32) !Image {
+ return error.ImageDecodeUnavailable;
+ }
+
+ pub fn resize(_: *const Image, _: u32, _: u32) Image {
+ return .{};
+ }
+
+ pub fn deinit(_: *Image) void {}
+};