Skip to content

Add a browser playground to the website - #34

Open
brainkim wants to merge 8 commits into
mainfrom
playground
Open

Add a browser playground to the website#34
brainkim wants to merge 8 commits into
mainfrom
playground

Conversation

@brainkim

@brainkim brainkim commented Aug 12, 2026

Copy link
Copy Markdown
Member

Adds a /playground/ page to the website: a code editor beside an xterm.js terminal, with TermDOM running in the visitor's browser and rendering live as they type. Nothing is sent to a server.

The page uses the crank.js.org playground stack: the editor is @b9g/revise's ContentArea element with Prism tokenizing (CodeEditor/ContentArea components ported from the crank website and adapted to this site's styling), and the page is Crank components. No CodeMirror.

How it works:

  • TermDOM boots in the browser unmodified. An XtermTransport implements the public TerminalTransport interface against an xterm.js instance: engine output writes to the terminal, xterm key/mouse input feeds back, cols/rows are live, colorDepth: "rgb". xterm answers the session's DSR/DECRPM queries itself.
  • Build is plain shovel: the client is an asset import, the page a static route in the sitemap, xterm CSS loaded on that page only. Bundle: 1.44 MB raw / 411 KB gzip (21% smaller than the CodeMirror version it replaces).
  • User code runs as an AsyncFunction whose scope shadows the page: document and the DOM interfaces are the TermDOM instance's, window/globalThis are term.window, timers are tracked wrappers canceled on rerun, and fetch/storage/location/workers are bound to undefined. (The crank playground evals in an iframe; that model doesn't apply here because user code must reach the in-page TermDOM bound to the in-page xterm.) A rerun tears the previous instance down before starting.
  • Examples in a select: hello world, the README progress bar, a form, lists. Errors surface in a status line. Run button, Cmd/Ctrl-Enter, and debounced auto-run.

Deps: @b9g/revise added; all CodeMirror packages removed; both lockfiles updated and the frozen bun check passes.

Verified against the built site in headless Chromium: 22 checks — rendering, example switching, dead timers after rerun, keystrokes through xterm, error surfacing, isolation boundaries, editor behavior (tokenized lines, gutter/line agreement, caret surviving re-tokenization, undo, Cmd-Enter), zero console errors. check-links passes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C8sSHf9EvBZroVnsXJbJSD


Second commit: the homepage's example recordings become inline playgrounds. ![…](playground:id) embeds (same mechanism as cast:) hydrate lazily — bundle fetch at 600 px, mount at 200 px — with the program statically tokenized as the pre-JS state, so the page loads fast and reads without JavaScript. Converted: animated, form. The solitaire hero stays a GIF (excluded from the sandbox), and the asciinema pipeline is untouched for README assets.

The playground page and the embeds share one Workbench component. Examples now come from the repo's examples/ directory at build time — nine browser-viable files verbatim minus the attach preamble (types erased by module.stripTypeScriptTypes, the same erasure node applies); ten excluded for node builtins, fetch, or bare import statements (the sandbox compiles an AsyncFunction body, where import is a syntax error, and was not weakened). All page prose is gone; the one surviving hint is ⌘⏎ on the Run button.

Fixed along the way: gutter drift (fractional 22.4 px line boxes rounding differently per column — now an integer line-height shared by gutter and code, verified Δ=0 px at lines 1/27/53) and Cmd/Ctrl-Enter (xterm's stopPropagation and the editor's unguarded Enter handler both ate it — capture-phase listener, modifier-aware indent; the check now proves a rerun on real key presses in both focus contexts).

Verification: 76 headless checks across the playground page and homepage embeds, zero console errors; check-links passes.

@brainkim
brainkim force-pushed the playground branch 3 times, most recently from 7ef6246 to 30b4c50 Compare August 13, 2026 23:24
brainkim and others added 5 commits August 14, 2026 15:16
A /playground/ page where the editor's code runs against a TermDOM attached
to an xterm.js pane in the reader's own browser. The engine reaches the
emulator through a TerminalTransport like any other: writes are
Terminal.write, reads are onData, and sharesScreen is false because the pane
holds no shell prompt to anchor beneath.

The programs in the picker are the repository's own examples/*.ts. They are
read off disk at static-generation time, stripped of their type annotations
the way node strips them to run the files, and shorn of the three lines the
playground has already done -- the import, the construction and the attach --
and they travel to the page as JSON. What a visitor edits is the file that
ships with the library. The nine that run are the nine that reach for nothing
the browser lacks; the rest want node:fs, argv, fetch, or an import the
runner cannot resolve, since a program is compiled as a function body rather
than a module.

The editor is revise's content-area, tokenized by Prism and rendered a line
at a time by Crank -- the highlighted elements are the editable document
rather than a copy laid over one, so a keystroke changes the DOM, the element
reports it as an edit, and a keyer holds the lines the edit did not touch
still. A row is a whole number of pixels tall in both the gutter and the
code: a fractional line box rounds independently in each column, and the
halves of a pixel add up to a number sitting above its line. The page is a
Crank component too, and the mount point is all the server sends: every other
view here reads without JavaScript, and this one cannot.

Programs are compiled into an async function whose parameters shadow the page
-- document and window are the terminal's, the timers are tracked so a rerun
can cancel them, and fetch, storage and the frame ancestors are bound to
undefined -- so nothing typed here can reach the page it is typed on. Errors,
thrown or syntactic, land in a status line instead of a blank pane.

The page carries no copy: an editor, a terminal, a picker, a run control and
that status line. Ctrl/Cmd-Enter runs from either place the keyboard can be,
which takes a capture-phase listener -- the emulator cancels the keys it
handles and the editor turns Enter into an indented newline, so neither event
reaches the window on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two of the casts on the front page were programs that run in a browser, so
they run there now: `![examples/form.ts](playground:form)` is a scheme
alongside `cast:`, and where the GIF was there is an editor and a terminal
with that example already in them. The recordings stay where the program
cannot follow -- solitaire wants an import the runner cannot resolve, the
file browser wants a filesystem -- and the asciinema pipeline stays whole.

An embed is the playground page's own workbench, not a second copy of it:
the picker was the only thing the page had that an embed does not, so it
moved out into a `controls` slot and everything under it -- the editor, the
pane, the runner, the toolbar, the run shortcut -- is shared. The shortcut
listens on each instance's own element rather than the window, so the
keyboard runs the workbench it is in.

The two contexts differ in how much terminal they get, which is a transport
value and so is per instance. The page is somewhere to work and keeps the
terminal the examples are written against, 80x24. An embed is a figure in an
argument, and the programs it holds paint 11x35 and 7x45, so it gets 56x14 --
a pane far taller than its program is a black void with a program at the top
of it. The pane is as wide as the emulator inside it and no wider, the editor
takes the rest, and the two sit side by side only where that leaves the
editor sixty-four columns of its own text; below that they stack. It is a
container query, because the question is how much room the workbench has and
the same workbench sits in a 900px column and across a page. The editor's box
is a whole number of its own lines, so it never ends halfway through one, and
a document handed in from outside opens at line one.

The run button and the status share one row above the panes instead of
floating apart with the panes between them.

Nothing boots at load. Each figure ships the program highlighted at build
time, which is what a reader without JavaScript gets and what everyone sees
until the embed comes near the viewport; an observer fetches the bundle at
600px and each instance mounts at 200px. The code block is held to the
editor's height so hydrating does not move the page under anyone. Both
instances then run at once -- two engines and two emulators come to about
20MB of heap, and an emulator whose element is off screen pauses its own
renderer -- so there is no scheme here for stopping one to start another.

Runs within a pane go on a chain instead of racing under a generation
counter: starting a program is asynchronous and stopping one writes to the
screen, so the only safe order is one at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C8sSHf9EvBZroVnsXJbJSD
The panes had a border and a radius each and sixteen pixels of page between
them, with the run button and the status floating above the pair. That is
three surfaces where there is one thing. The frame moves outward: a hairline
border and the 8px radius the site gives `pre`, the install command and the
cast player, drawn once around the whole workbench. The halves inside it lose
their edges and meet on a rule, which turns with the layout -- a left border
side by side, a top border stacked -- and both stretch to the taller of them,
so the frame closes on a straight edge whichever way they sit.

The toolbar comes inside the frame as its title bar, on the page's background
rather than the surface, a step back from the panes the way the page sits
behind its own surfaces. Its controls shrink to the size of controls: the
picker and the run button take the site's surface, hairline and radius, and
both answer to hover and to focus-visible. Where an embed has no picker to
name the file, a filename sits at the head of the bar in the picker's place.

The gutter takes the background too. The line numbers are not the code, and
the column they sit in can say so without a second border.

The code samples stop asking for Consolas. The page is already set in the
monospace the reader's system prefers, and a font stack of Prism's own puts
every sample on the site in a second typeface.

The pane's own padding is what separates the emulator from the frame, so the
side-by-side threshold counts that padding and the rule between the halves
instead of a gap that is no longer there. The terminal half fills the column
it is given and the emulator sits at its top left: the surplus is terminal
background, a screen with nothing painted on it rather than a hole.

Heading anchors get the styling the markdown renderer has been emitting them
for: a muted mark beside the text that comes forward when the heading is
under the pointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page and the README were describing the same library in two vocabularies.
The README's sentences are the ones that landed, so the page takes them
verbatim: the tagline, the feature bullets, the How it works paragraph, and
the captions under the two recordings. Nothing is paraphrased and nothing is
newly written, including the title and description in the head and the
description in the structured data, which had kept an older pitch.

The hero note under the install command is gone -- it said the tagline again,
one line under the tagline -- and so are the two code blocks that stood in
front of an embed showing the same program running. A reader who wants the
spinner has it in the editor below the sentence, and the sentence now says
that editing it runs it again.

A figure states what it is either way it renders. A recording carries its
alt text as a caption; a live embed is a framed surface with a titled bar
across the top before it hydrates and after, so the swap changes what the
figure does and not what it is. The static state is held to twenty whole
lines and the padding above the first, so the box ends where a line does and
hydrating does not move the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
brainkim and others added 3 commits August 14, 2026 19:12
The README's progress-bar card becomes examples/progress-bar.ts, a file
the playground can run, and the home page embeds it live where its
recording used to play. The tree recording goes; the section keeps its
prose and its keydown snippet and points at examples/tree.ts, which
needs a filesystem and cannot run in a browser. Solitaire keeps its GIF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chromium on macOS hands CJK-IME keys to the page as ordinary keydowns
rather than keyCode 229, so xterm emitted each raw jamo and prevented
the default -- the very thing that kept composition from starting
(xterm.js#5348). A custom key handler declines composing and Hangul/kana
keydowns; the browser composes on xterm's own textarea and the finished
syllable arrives through the normal composition path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C8sSHf9EvBZroVnsXJbJSD
Chromium and WebKit fail a Korean IME in opposite directions, and the
handler written for the first is wrong for the second. Chromium sends the
jamo as ordinary keydowns and the emulator answers them, which is what
stops the composition; WebKit fires no composition at all and replaces the
syllable through `insertReplacementText`, which the emulator drops.

So each engine gets its own intervention and neither gets the other's.
WebKit's holds the syllable the engine composed -- it does not compose
anything itself -- and stops the events that would have sent it in pieces,
sending it once when the composition ends. Everything else keeps stock
behaviour.

`verify:ime` replays both traces as events on the emulator's textarea, in
the engines themselves, and reads back what would have gone to the
transport.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant