Skip to content

HyperLab in a browser: the website, and a WebAssembly shell - #4

Merged
JGalego merged 10 commits into
mainfrom
claude/hyperlab-retro-website-bico9i
Aug 2, 2026
Merged

JGalego merged 10 commits into
mainfrom
claude/hyperlab-retro-website-bico9i

Conversation

@JGalego

@JGalego JGalego commented Aug 2, 2026 •

Copy link
Copy Markdown
Owner

A retro landing page telling HyperCard's story, and the whole application
running on it — the same renderer the desktop draws, over the same runtime
compiled to WebAssembly.

The playground is the application

The desktop keeps its shell behind one module (apps/desktop/src/api.ts, a
typed wrapper per command), and the renderer never learns which shell
answers. Vite swaps that one module for apps/web/src/api.ts; nothing else
in the desktop tree is touched, so the two interfaces cannot drift apart —
they are the same files. apps/desktop/ has no changes in this diff at all.

Behind it, crates/web wraps the same crates the Tauri shell wraps, one
exported function per desktop command.

What a browser makes hard, and what was done about it

A script has to block on a dialog. ask stops the script and the answer
lands in it on the next line. The runtime lives in a Web Worker; when a
script asks, the worker parks on Atomics.wait while the page — still
responsive — shows the same dialog component the desktop shows, and writes
the reply into a SharedArrayBuffer. The runtime is none the wiser. GitHub
Pages cannot send the isolation headers that needs, so a vendored
coi-serviceworker.js (MIT) adds them, at the cost of one reload on first
visit; without isolation the runtime falls back to the main thread and
window.prompt.

There is no file system. A stack travels as the single-file JSON
hyperlab-persistence already speaks — the same file the desktop opens.

There is no clock. SystemTime::now traps on wasm32, so
hyperlab_stack::set_clock lets a host name one and the web shell names the
browser's. The interpreter's the date and random follow it.

There are no fonts. Artwork carries words — Cluedo's board names its
rooms — and both exporters asked the operating system for a typeface. They
now take one from their host too. Chasing this found a real bug beyond the
browser: usvg resolves the generic families through names of their own, and
sans-serif means Arial until told otherwise, so a stock Linux box with no
Arial was losing labels as surely as a page was. The stand-in now turns on
whether the family resolves, which fixes both and leaves a well-stocked
machine alone.

Keys never leave the browser

The desktop keeps API keys in the OS keychain, one-way. The web keeps the
same shape with the parts a page has: a key goes into this browser's
storage, no command reads one back out, and requests go straight from the
browser to the configured provider. The site is static files, so there is no
server of ours for a key to visit. A mock provider answers locally, so the
whole AI surface can be tried with no key at all.

Feature parity

Everything on the desktop's File menu is here, including a PDF of the cards,
a PNG of the map, a self-contained web page, and a Decker deck. A Cluedo PDF
exported in the browser matches the desktop's checked-in docs/cluedo.pdf:
three pages, thirteen pieces of artwork, the same words, an embedded face
for the labels. The one deliberate difference is Save / Save As…
collapsing into Download Stack.

Enabling changes to existing crates

  • hyperlab-ai-providers gained a default-on native feature. Without it
    the crate is only the wire protocol, for a host that brings its own
    transport; with it, nothing changes.
  • hyperlab-persistence gained single_file_string and
    stack_from_single_file.
  • hyperlab-mcp's client is native-only — it spawns a program, which a
    browser cannot do.
  • The Todo example gained the trailing newline its Add button always
    assumed.

Verified

CI is green on 036fe74, all seven jobs. Beyond that, the built site was
driven in Chromium: all six examples open and draw, a script's ask blocks
and its reply reaches it, undo works, the mock assistant answers, ai("…")
answers mid-script, and both new exports produce valid files.

One thing left for a human

Settings ▸ Pages ▸ Source must be set to GitHub Actions before the
deploy workflow has anywhere to publish.

docs/website.md explains the arrangement in full.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr

claude added 10 commits August 2, 2026 12:29
…ystem or an HTTP client

The browser build needs both: it brings its own transport (fetch), so the
provider crate's encode/decode become public free functions behind a default
'native' feature that keeps reqwest and the clients exactly as they were; and
it has no disk, so a stack can now round-trip through a string with
single_file_string and stack_from_single_file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr
…ompiled to WebAssembly

One exported function per desktop command, so the React renderer cannot tell
which shell it is behind. Dialogs cross a JsHost object the page supplies —
the worker blocks where the desktop blocked a thread — a stack travels as
single-file JSON instead of a path, keys live in browser storage behind the
same one-way rule as the OS keychain, and AI requests go through the
browser's own fetch straight to the configured provider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr
…he browser

apps/web serves two pages from GitHub Pages. The landing page tells
HyperCard's story — 1987, the teachers, the museums, Myst — dressed in the
Neo Classic theme's own values. The playground is the desktop's React
renderer, unchanged, over the WebAssembly runtime: Vite swaps one module
(api.ts) and everything else — cards, inspector, assistant, map, dialogs —
is the same code the desktop draws.

The runtime lives in a Web Worker so a script can block on a real,
HyperCard-shaped dialog: the worker parks on Atomics.wait while the page
shows it, which is the desktop's blocking-thread arrangement rebuilt from
browser parts. GitHub Pages cannot send the isolation headers that allows,
so a vendored coi-serviceworker (MIT) adds them; without isolation the
runtime falls back to the main thread and the browser's own prompt.

Files became uploads and downloads of the single-file stack JSON, pictures
arrive through a file picker, and the six examples are packed by a new
persistence example and fetched from the site. API keys live in browser
storage behind the same one-way rule as the OS keychain and travel only to
the configured provider — the site is static files, so there is no server
for them to visit. The stack crate's clock became pluggable along the way:
SystemTime traps on wasm32, so the web shell names the browser's clock, and
the interpreter's time and randomness follow it. The Todo example also
gained the trailing newline its Add button always assumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr
pages.yml builds the wasm runtime, packs the examples, and deploys apps/web
to GitHub Pages on every push to main; a matching CI job runs the same build
on pull requests so it cannot rot. docs/website.md explains the arrangement
— one swapped module, a worker parked on Atomics.wait behind real dialogs,
keys that never leave the browser — and the README and changelog point at
the site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr
Plain section titles, shorter paragraphs, and fewer slogans: Examples
instead of a counted headline, AI and The desktop application instead of
coinages, one sentence on keys instead of four. Your history text is
untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr
wasm-pack's release profile downloads a binaryen release to run wasm-opt,
and a failed download fails the build — which is how the Pages deploy would
have broken on a bad day. The optimiser was worth about a tenth of a 313 KB
artifact, so the build gives it up and stays hermetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr
Both crates already compiled to WebAssembly untouched; what they could not
do was find a typeface. Artwork carries words — Cluedo's board names its
rooms — and a page cannot read the machine's fonts, so those labels would
have gone missing from every export.

So export and decker each take a font from their host, the way the stack
takes a clock: registered fonts join the machine's own, and stand in for the
generic families only where the platform found none, which leaves a desktop
export exactly as it was. The site fetches Liberation Sans (OFL) the first
time someone exports, so the 400 KB never lands on an ordinary visit.

A PDF crosses the worker boundary as bytes rather than base64 through the
JSON channel. Driving the real page, a Cluedo PDF exported in the browser
matches the desktop's checked-in one: three pages, thirteen pieces of
artwork, the same words, an embedded face for the labels.

The MCP client is now native-only. It spawns a program, which a browser
cannot do, and compiling it for wasm left a warning that -D warnings would
have failed CI on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr
`cargo doc` runs on the host, where crates/web's api module does not exist
— it is compiled for wasm32 only — so a link to it cannot resolve, and
ProviderConfig was never in scope to link at all. CI documents with
-D warnings, which is how both were caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr
The pre-pull-request list named the desktop app and stopped there, so
somebody touching a core crate had no way to know they had just broken a
wasm build. It now covers the website, and the documentation build that
caught two links good everywhere but there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr
CI caught this and my machine could not: the test asserted that a picture's
words were drawn, which held on a laptop full of fonts and failed on a bare
runner.

The rule was wrong, not just the test. usvg falls back to the generic
families, and those resolve through names of their own — sans-serif means
Arial until something says otherwise. Keying the stand-in on "the platform
found no fonts at all" missed the commoner case: a machine with fonts but
no Arial, which is a stock Linux box as much as it is a browser, and which
was losing its labels either way. So the stand-in now turns on whether the
family resolves, which fixes both and still leaves a machine that can
already draw sans-serif alone.

The test now asserts only what holds anywhere — the document comes out
whole. That a registered font reaches the converter, and that the generic
family it stands in for really was unresolvable, are unit tests against a
database built in the test. That a browser ends up with the labels is
checked by driving the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7eaxUAqy2gKQSBNnYZoFr
@JGalego JGalego changed the title Let the AI wire protocol and single-file stacks work without a file system or an HTTP client HyperLab in a browser: the website, and a WebAssembly shell Aug 2, 2026
@JGalego
JGalego merged commit 0668abf into main Aug 2, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants