From 443a28f197ad3e5fda4724ec0ad898b93386e2aa Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:40:52 -0500 Subject: [PATCH 1/4] web: embed-followups --- .github/workflows/ci.yml | 6 ++ web/AGENTS.md | 4 ++ web/fizzy.js | 86 ++++++++++++++++++++----- web/test/embed.test.mjs | 132 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 15 deletions(-) create mode 100644 web/test/embed.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c19f2b3ed..3a92c47df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,6 +239,12 @@ jobs: shell: bash run: bash scripts/ci-retry.sh scripts/check-web-layout.sh + # Two embedded copies on one page, against a fake DOM: keys, full screen, one shared compile. + - name: Test embedding two copies + if: matrix.name != 'Windows' + shell: bash + run: node web/test/embed.test.mjs + # The tapes fizzyed.it serves beside the app: a script or `docs/demos/` tape that no longer # builds or reads back fails here, not on the deploy after merge. - name: Build the hosted demo tapes diff --git a/web/AGENTS.md b/web/AGENTS.md index 579e85caa..ba282de88 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -14,6 +14,10 @@ The page and its JavaScript (`index.html`, the app's `fizzy.js`, workers). The Z turns its query (`?storage=`, `?persist=`, `?worker=`, `?form=`, `?plugin=`, ...) into options and shows `onReady`/`onError` itself, and an app built on fizzy passes its own. Nothing wraps `WebAssembly.instantiateStreaming`. +- **Several copies share a page.** `fizzy.js` keeps no per-page state but the compiled wasm (shared by + URL) and the last copy used. A window-level listener must check its event is for this copy + (`ownsKey`), and full screen takes `fullscreenElement` (default the canvas's parent), never the + document. `node web/test/embed.test.mjs` mounts two copies against a fake DOM (CI runs it). - **`web.js` is dvui's,** with `patches/dvui` applied at build time (`docs/DEPENDENCIES.md`). A change to it is a change to a patch: make it on the dvui branch the patches come from and export them again, so it can go upstream. Never keep a copy of `web.js` here. diff --git a/web/fizzy.js b/web/fizzy.js index 3a6e78b27..95e1e0f98 100644 --- a/web/fizzy.js +++ b/web/fizzy.js @@ -16,6 +16,31 @@ import { Dvui } from "./web.js?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_B // overwrites the real one's settings, layout or plugins. Stamped by the build. const buildStorageName = "__FIZZY_WEB_STORAGE__"; +/** The editor's compiled wasm by URL, shared by every copy on the page: one download, one compile. */ +const compiledEditors = new Map(); + +/** + * The compiled editor at `url`, started on the first ask and shared after. Null when it could not + * be fetched or compiled (a later copy tries again); the caller then fetches it itself. + * @param {string} url + * @returns {Promise} + */ +function compiledEditor(url) { + let compiled = compiledEditors.get(url); + if (!compiled) { + compiled = WebAssembly.compileStreaming(fetch(url)).catch((err) => { + console.warn("fizzy: compiling the editor early failed; the copy fetches it", err); + compiledEditors.delete(url); + return null; + }); + compiledEditors.set(url, compiled); + } + return compiled; +} + +/** The copy whose canvas or input was last used, which gets keys that land on the page itself. */ +let activeCopy = null; + /** * @typedef {object} FizzyOptions * @property {HTMLCanvasElement} canvas - Where the editor draws and takes its input. @@ -31,6 +56,8 @@ const buildStorageName = "__FIZZY_WEB_STORAGE__"; * Default: in a worker on Chromium only. Never in a worker with `imports`. * @property {"desktop" | "tablet" | "mobile"} [form] - The form factor to take, in place of the * device's (`core.platform.formFactor()`). Anything else is ignored. + * @property {HTMLElement} [fullscreenElement] - What View › Toggle Full Screen shows full screen. + * Default: the canvas's parent, so an embed fills the screen and the page around it does not. * @property {string[]} [plugins] - Ids of plugins to load from `plugins//.wasm` beside the * app once it is up, each in place of an installed one of the same id: how a developer tries a * build. @@ -268,11 +295,7 @@ export class Fizzy { // View › Toggle Full Screen. The request runs a frame after the key press or click // that asked for it, which is still inside the browser's user-activation window. fizzy_web_toggle_fullscreen() { - if (document.fullscreenElement) { - document.exitFullscreen().catch(() => {}); - } else if (document.documentElement.requestFullscreen) { - document.documentElement.requestFullscreen().catch((err) => console.warn("fizzy: full screen refused:", err)); - } + toggleFullscreen(); }, // `Clock.real` for the app's `Io` (src/backend/web_io.zig). fizzy_web_now_real_ms() { @@ -785,13 +808,40 @@ export class Fizzy { // "pinch fingers together = zoom out". let dvuiApp = null; const dvuiCanvas = opts.canvas; + // The element dvui's text input lives in (set once the editor is up): where text arrives. + let textInput = null; + for (const type of ["pointerdown", "focus"]) dvuiCanvas.addEventListener(type, () => { activeCopy = this; }, { capture: true }); + + // View › Toggle Full Screen shows this copy's element, not the whole page, and switches to it + // when another element (another copy) is already full screen. + function fullscreenTarget() { + return opts.fullscreenElement ?? dvuiCanvas.parentElement ?? dvuiCanvas; + } + function toggleFullscreen() { + const target = fullscreenTarget(); + if (document.fullscreenElement === target) { + document.exitFullscreen().catch(() => {}); + } else if (target.requestFullscreen) { + target.requestFullscreen().catch((err) => console.warn("fizzy: full screen refused:", err)); + } + } // Shortcuts the app binds are the app's, not the browser's: ⌘S must not also save the // page. Capture phase, so this runs before dvui's own listener (which still receives the // key — preventDefault stops the browser, not the event). The app answers synchronously // from its keymap (`Keybinds.webKeyBound`), which also keeps typing, copy/cut/paste and // unbound chords with the browser. + // + // Only the keys meant for this copy: typed into the page's own field, or a chord aimed at + // another copy, they stay the page's. A key lands on this copy when its canvas or its text + // input has the focus, or when nothing has and this copy was the last one used. + const ownsKey = (ev) => { + const target = ev.target; + if (target === dvuiCanvas || (textInput && target === textInput)) return true; + return (target === document.body || target === document.documentElement) && activeCopy === this; + }; window.addEventListener("keydown", (ev) => { + if (!ownsKey(ev)) return; const ex = wasmInstance && wasmInstance.exports; if (!ex || !ex.FizzyWebKeyBound) return; const bytes = utf8encode(ev.key); @@ -806,9 +856,13 @@ export class Fizzy { // and those chords stay the browser's. document.addEventListener("fullscreenchange", () => { const kb = navigator.keyboard; + const full = document.fullscreenElement === fullscreenTarget(); + // The text input is the page's, outside the element shown full screen, where it cannot + // take focus; it goes in with the editor and comes out again. + if (textInput) (full ? fullscreenTarget() : document.body).prepend(textInput); if (!kb || !kb.lock) return; - if (document.fullscreenElement) kb.lock().catch(() => {}); - else kb.unlock(); + if (full) kb.lock().catch(() => {}); + else if (!document.fullscreenElement) kb.unlock(); }); dvuiCanvas.addEventListener("wheel", (ev) => { if (ev.ctrlKey && ev.deltaMode === 0) { @@ -898,6 +952,7 @@ export class Fizzy { hidden.setAttribute("autocapitalize", "none"); Object.assign(hidden.style, { position: "absolute", left: 0, top: 0, padding: 0, border: 0, margin: 0, opacity: 0, zIndex: -1 }); document.body.prepend(hidden); + textInput = hidden; let textInputRect = []; // Focus never scrolls the page; and a copy embedded in another page (fizzyed.it's // examples, in an iframe) takes none until it is clicked, tapped or tabbed into. @@ -1018,8 +1073,7 @@ export class Fizzy { } break; case "fullscreen": - if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); - else if (document.documentElement.requestFullscreen) document.documentElement.requestFullscreen().catch((err) => console.warn("fizzy: full screen refused:", err)); + toggleFullscreen(); break; case "oauth": { let blobUrl = null; @@ -1188,10 +1242,7 @@ export class Fizzy { // editor's download used to wait with it, tens of seconds on a slow link. The worker is // handed the compiled module and fetches nothing itself, so a page shown at once never // downloads it twice. Null on failure: the worker then fetches it as before. - const wasmModule = WebAssembly.compileStreaming(fetch(wasmUrl)).catch((err) => { - console.warn("fizzy: compiling the editor early failed; the worker fetches it", err); - return null; - }); + const wasmModule = compiledEditor(wasmUrl); waitForLayout().then(() => startWorker(wasmUrl, wasmModule)).catch((err) => { console.error(err); onError(`Failed to load the editor (${err instanceof Error ? err.message : String(err)}).`); @@ -1201,17 +1252,22 @@ export class Fizzy { waitForLayout() .then(() => { const app = new Dvui(); - return WebAssembly.instantiateStreaming(fetch(wasmUrl), { + const imports = { ...opts.imports, dvui: app.imports, fizzy: fizzyImports(() => wasmInstance), env: { __indirect_function_table: functionTable }, - }).then((result) => { + }; + return compiledEditor(wasmUrl).then((module) => module + ? WebAssembly.instantiate(module, imports).then((instance) => ({ instance })) + : WebAssembly.instantiateStreaming(fetch(wasmUrl), imports) + ).then((result) => { wasmInstance = result.instance; if (opts.onInstance) opts.onInstance(result.instance); app.setInstance(result.instance); app.setCanvas(dvuiCanvas); app.run(); + textInput = app.hidden_input ?? null; return app; }); }) diff --git a/web/test/embed.test.mjs b/web/test/embed.test.mjs new file mode 100644 index 000000000..2e9860824 --- /dev/null +++ b/web/test/embed.test.mjs @@ -0,0 +1,132 @@ +// Two copies of fizzy on one page, against a fake DOM and a fake wasm: the page's own fields keep +// their keys, a chord goes to the copy it is aimed at, full screen takes the embed, and the editor +// is fetched and compiled once. Run: `node web/test/embed.test.mjs`. + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +// fizzy.js as the build stamps it, beside a `web.js` that is only a stand-in for dvui's. +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fizzy-embed-")); +const source = fs.readFileSync(new URL("../fizzy.js", import.meta.url), "utf8") + .replace("__FIZZY_WEB_STORAGE__", "test") + .replaceAll("?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256", ""); +fs.writeFileSync(path.join(dir, "fizzy.js"), source); +fs.writeFileSync(path.join(dir, "web.js"), ` +export class Dvui { + constructor() { this.imports = {}; this.hidden_input = null; } + setInstance(instance) { this.instance = instance; } + setCanvas(canvas) { this.canvas = canvas; } + run() { this.hidden_input = globalThis.document.createElement("input"); } + requestRender() {} +} +`); + +class Target { + constructor() { this.listeners = {}; this.children = []; this.parentElement = null; } + addEventListener(type, fn) { (this.listeners[type] ??= []).push(fn); } + removeEventListener() {} + dispatch(type, ev) { for (const fn of this.listeners[type] ?? []) fn(ev); } + prepend(child) { child.parentElement = this; this.children.unshift(child); } +} +class Element extends Target { + constructor() { super(); this.style = {}; this.fullscreen = 0; } + setAttribute() {} + focus() {} + getBoundingClientRect() { return { left: 0, top: 0, right: 100, bottom: 100 }; } + requestFullscreen() { document.fullscreenElement = this; this.fullscreen++; document.dispatch("fullscreenchange", {}); return Promise.resolve(); } +} +class Canvas extends Element { + constructor() { super(); this.clientWidth = 100; this.clientHeight = 100; } + getContext() { return {}; } +} +const document = Object.assign(new Target(), { + fullscreenElement: null, + baseURI: "http://localhost/", + body: new Element(), + documentElement: new Element(), + createElement: (tag) => (tag === "canvas" ? new Canvas() : new Element()), + exitFullscreen() { document.fullscreenElement = null; document.dispatch("fullscreenchange", {}); return Promise.resolve(); }, +}); +const window = Object.assign(new Target(), { + devicePixelRatio: 1, scrollX: 0, scrollY: 0, localStorage: new Map(), parent: null, + dispatchEvent() {}, matchMedia: () => ({ matches: false, addEventListener() {} }), +}); +window.parent = window; +Object.assign(globalThis, { + window, document, HTMLCanvasElement: Canvas, Event: class {}, requestAnimationFrame: (f) => setTimeout(f, 0), + screen: { width: 800, height: 600 }, TextEncoder, TextDecoder, location: { href: "http://localhost/" }, +}); +Object.defineProperty(globalThis, "navigator", { value: {}, configurable: true }); + +// The editor: a module that answers FizzyWebKeyBound for the chord Ctrl+S, counting its asks. +let fetches = 0; +let compiles = 0; +const bound = []; +const toggles = []; +const memory = new WebAssembly.Memory({ initial: 1 }); +globalThis.fetch = (url) => { fetches++; return Promise.resolve({ url }); }; +WebAssembly.compileStreaming = async () => { compiles++; return { fake: true }; }; +WebAssembly.instantiate = async (module, imports) => { + const id = bound.length; + bound.push([]); + toggles.push(imports.fizzy.fizzy_web_toggle_fullscreen); + return { + exports: { + memory: { buffer: memory.buffer }, + FizzyWebKeyBuffer: () => 0, + FizzyWebKeyBound: (len, mods) => { bound[id].push([len, mods]); return 1; }, + }, + }; +}; +WebAssembly.Table = class {}; + +const { Fizzy } = await import(pathToFileURL(path.join(dir, "fizzy.js")).href); + +const mount = async (parent) => { + const canvas = document.createElement("canvas"); + canvas.parentElement = parent; + const ready = new Promise((resolve, reject) => { + new Fizzy({ canvas, worker: false, persist: false, onReady: resolve, onError: reject, onInstance: () => {} }).run(); + }); + await ready; + return canvas; +}; +const a = await mount(new Element()); +const b = await mount(new Element()); + +assert.equal(compiles, 1, "two copies share one compile"); +assert.equal(fetches, 1, "two copies share one download"); + +const key = (target) => { + const ev = { target, key: "s", ctrlKey: true, metaKey: false, altKey: false, shiftKey: false, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; + window.dispatch("keydown", ev); + return ev; +}; + +const field = new Element(); +assert.equal(key(field).defaultPrevented, false, "a chord typed into the page's own field stays the page's"); +assert.deepEqual(bound, [[], []], "the editors were not asked"); + +assert.equal(key(a).defaultPrevented, true, "a chord on the first copy is its"); +assert.deepEqual(bound.map((asks) => asks.length), [1, 0], "only the copy it was aimed at is asked"); +assert.equal(key(b).defaultPrevented, true); +assert.deepEqual(bound.map((asks) => asks.length), [1, 1]); + +b.dispatch("pointerdown", {}); +assert.equal(key(document.body).defaultPrevented, true, "with nothing focused the last copy used has the key"); +assert.deepEqual(bound.map((asks) => asks.length), [1, 2]); + +// View › Toggle Full Screen: the copy's own element, not the page; the other copy switches to its own. +const parentA = a.parentElement; +const parentB = b.parentElement; +toggles[0](); +assert.equal(document.fullscreenElement, parentA, "full screen shows the embed, not the page"); +assert.equal(document.documentElement.fullscreen, 0); +toggles[1](); +assert.equal(document.fullscreenElement, parentB, "the other copy takes full screen for itself"); +toggles[1](); +assert.equal(document.fullscreenElement, null, "and a second toggle leaves it"); +console.log("embed.test: ok"); From 6439ba46420c9dd84080ed20601e8f618e8ddb15 Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:29:00 -0500 Subject: [PATCH 2/4] web: move the embed test into web/tests Main keeps its web JS tests in web/tests/; the embed test sat in a second directory. CI still runs it in its own step: it needs no built web.js, unlike scripts/test-web-text-input.sh. Agent-Team: linux Worker: claude Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 2 +- web/AGENTS.md | 2 +- web/{test => tests}/embed.test.mjs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename web/{test => tests}/embed.test.mjs (98%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbb9dd0f7..0d95c70a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -276,7 +276,7 @@ jobs: - name: Test embedding two copies if: matrix.name != 'Windows' shell: bash - run: node web/test/embed.test.mjs + run: node web/tests/embed.test.mjs # The tapes fizzyed.it serves beside the app: a script or `docs/demos/` tape that no longer # builds or reads back fails here, not on the deploy after merge. diff --git a/web/AGENTS.md b/web/AGENTS.md index ba282de88..d114954ba 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -17,7 +17,7 @@ The page and its JavaScript (`index.html`, the app's `fizzy.js`, workers). The Z - **Several copies share a page.** `fizzy.js` keeps no per-page state but the compiled wasm (shared by URL) and the last copy used. A window-level listener must check its event is for this copy (`ownsKey`), and full screen takes `fullscreenElement` (default the canvas's parent), never the - document. `node web/test/embed.test.mjs` mounts two copies against a fake DOM (CI runs it). + document. `node web/tests/embed.test.mjs` mounts two copies against a fake DOM (CI runs it). - **`web.js` is dvui's,** with `patches/dvui` applied at build time (`docs/DEPENDENCIES.md`). A change to it is a change to a patch: make it on the dvui branch the patches come from and export them again, so it can go upstream. Never keep a copy of `web.js` here. diff --git a/web/test/embed.test.mjs b/web/tests/embed.test.mjs similarity index 98% rename from web/test/embed.test.mjs rename to web/tests/embed.test.mjs index 2e9860824..ec5a20b3e 100644 --- a/web/test/embed.test.mjs +++ b/web/tests/embed.test.mjs @@ -1,6 +1,6 @@ // Two copies of fizzy on one page, against a fake DOM and a fake wasm: the page's own fields keep // their keys, a chord goes to the copy it is aimed at, full screen takes the embed, and the editor -// is fetched and compiled once. Run: `node web/test/embed.test.mjs`. +// is fetched and compiled once. Run: `node web/tests/embed.test.mjs`. import assert from "node:assert/strict"; import fs from "node:fs"; From 9988af99036e666eae631ba2e5b69c94e4b1490e Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:29:23 -0500 Subject: [PATCH 3/4] web: give the embed test's web.js stand-in a TextInput Main's fizzy.js imports TextInput from web.js; the stand-in lacked it after the merge. Agent-Team: linux Worker: claude Co-Authored-By: Claude Sonnet 5.5 --- web/tests/embed.test.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/tests/embed.test.mjs b/web/tests/embed.test.mjs index ec5a20b3e..5246d9663 100644 --- a/web/tests/embed.test.mjs +++ b/web/tests/embed.test.mjs @@ -15,6 +15,10 @@ const source = fs.readFileSync(new URL("../fizzy.js", import.meta.url), "utf8") .replaceAll("?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256", ""); fs.writeFileSync(path.join(dir, "fizzy.js"), source); fs.writeFileSync(path.join(dir, "web.js"), ` +export class TextInput { + keydown() {} + clear() {} +} export class Dvui { constructor() { this.imports = {}; this.hidden_input = null; } setInstance(instance) { this.instance = instance; } From 095720ee0dd047aee40b7ffdd07e26ec151e0314 Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:08:25 -0500 Subject: [PATCH 4/4] web: embed-followups (codex) Agent-Team: linux Worker: codex --- .github/workflows/ci.yml | 10 +++------- scripts/test-web-text-input.sh | 9 +++++++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d95c70a4..067388e2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -258,7 +258,9 @@ jobs: - name: Compile web (wasm) build if: matrix.name != 'Windows' shell: bash - run: bash scripts/ci-retry.sh zig build check-web + run: | + bash scripts/ci-retry.sh zig build check-web + scripts/test-web-text-input.sh --embed-only # `check-web` sets no `-Dapp-layout`, so the web build's layout wiring is a no-op there; this # builds wasm with an app-owned `.zon` and `.zig` layout, and fails if that wiring is gone. @@ -272,12 +274,6 @@ jobs: FIZZY_BUILD_SKIP: "1" run: bash scripts/ci-retry.sh scripts/check-web-layout.sh - # Two embedded copies on one page, against a fake DOM: keys, full screen, one shared compile. - - name: Test embedding two copies - if: matrix.name != 'Windows' - shell: bash - run: node web/tests/embed.test.mjs - # The tapes fizzyed.it serves beside the app: a script or `docs/demos/` tape that no longer # builds or reads back fails here, not on the deploy after merge. - name: Build the hosted demo tapes diff --git a/scripts/test-web-text-input.sh b/scripts/test-web-text-input.sh index 632417c10..5079e7b92 100755 --- a/scripts/test-web-text-input.sh +++ b/scripts/test-web-text-input.sh @@ -1,12 +1,17 @@ #!/bin/sh # The hidden input's tests (web/tests/text-input.test.js) against the web.js the web build -# writes: `zig build web` first. Needs node, or jsc on macOS. +# writes: `zig build web` first. Node also runs the two-copy embed check. +# Use --embed-only for the build-independent Node check; hidden-input tests also support jsc. set -eu cd "$(dirname "$0")/.." +if [ "${1:-}" = --embed-only ]; then + exec node web/tests/embed.test.mjs +fi js=zig-out/web/web.js [ -f "$js" ] || { echo "test-web-text-input: no $js; run zig build web" >&2; exit 1; } if command -v node >/dev/null; then - exec node web/tests/text-input.test.js "$PWD/$js" + node web/tests/text-input.test.js "$PWD/$js" + exec node web/tests/embed.test.mjs fi jsc=/System/Library/Frameworks/JavaScriptCore.framework/Versions/Current/Helpers/jsc [ -x "$jsc" ] || { echo "test-web-text-input: needs node (or macOS's jsc)" >&2; exit 1; }