Add a browser playground to the website - #34
Open
brainkim wants to merge 8 commits into
Open
Conversation
brainkim
force-pushed
the
playground
branch
3 times, most recently
from
August 13, 2026 23:24
7ef6246 to
30b4c50
Compare
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: `` 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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'sContentAreaelement with Prism tokenizing (CodeEditor/ContentAreacomponents ported from the crank website and adapted to this site's styling), and the page is Crank components. No CodeMirror.How it works:
XtermTransportimplements the publicTerminalTransportinterface against an xterm.js instance: engine output writes to the terminal, xterm key/mouse input feeds back,cols/rowsare live,colorDepth: "rgb". xterm answers the session's DSR/DECRPM queries itself.AsyncFunctionwhose scope shadows the page:documentand the DOM interfaces are the TermDOM instance's,window/globalThisareterm.window, timers are tracked wrappers canceled on rerun, andfetch/storage/location/workers are bound toundefined. (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.Deps:
@b9g/reviseadded; 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-linkspasses.🤖 Generated with Claude Code
https://claude.ai/code/session_01C8sSHf9EvBZroVnsXJbJSD
Second commit: the homepage's example recordings become inline playgrounds.
embeds (same mechanism ascast:) 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
Workbenchcomponent. Examples now come from the repo'sexamples/directory at build time — nine browser-viable files verbatim minus the attach preamble (types erased bymodule.stripTypeScriptTypes, the same erasurenodeapplies); ten excluded for node builtins,fetch, or bareimportstatements (the sandbox compiles an AsyncFunction body, whereimportis 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.