Skip to content

0.2.0 API review: canvas sizing and contexts, gauge styling, colorScheme, disposal, default actions - #145

Merged
brainkim merged 24 commits into
mainfrom
api-review
Oct 9, 2026
Merged

brainkim merged 24 commits into
mainfrom
api-review

Conversation

@brainkim

@brainkim brainkim commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

A pass over the public API before 0.2.0, so that 0.2.0 can mark general stability. Canvases, images and most of what changes here are new in 0.2.0, so only the items under Breaking change anything 0.1.x shipped.

Breaking

  • colorScheme is a TermDOM option, beside colorDepth: new TermDOM({colorScheme: "dark"}). It is gone from TerminalTransport and from the options of transportFromProcess and transportFromSSH. It holds until the terminal reports its background.
  • Disposal is asynchronous only. TermDOM has Symbol.asyncDispose, so await using term = new TermDOM() waits for the restores as await term.dispose() does. Symbol.dispose is gone: a disposal that writes to a terminal cannot promise it is done when it returns.
  • attach() takes no transport. The transport comes only from the constructor, new TermDOM({transport}). Swapping one in at attach time only duplicated the constructor option: the default transport does nothing until attach(), and a transport that isn't ready yet resolves ready when it is.
  • The keys scroll, as in a browser (Enumerate and specify default actions, i.e. behavior which is prevented with preventDefault() #84). ArrowUp and ArrowDown scroll a row, PageUp, PageDown and Space a page, Home and End to an edge, as the default action of a keydown nothing canceled, moving the nearest scrollable box around the focus and the document otherwise. A program that handles those keys itself needs preventDefault(); the git-log, tree, hacker-news, markdown and solitaire examples now call it.
  • Exit statuses follow the shell. Ctrl-C exits 130; window.close() exits 1 if the page let an exception escape, 0 otherwise; SIGINT, SIGHUP and SIGTERM exit 130, 129 and 143. All exited 0 before.
  • A page's ::part() does not reach inside built-in controls, as in browsers (this landed in 0.2.0: images and canvas follow-up, and a DOM that matches lib.dom #141; the launch post's demo no longer uses it).

Canvas sizing

A canvas's width and height are unitless, and HTML lays the canvas out at that many CSS pixels. TermDOM now does exactly that, whichever context the canvas gets, so asking for a context never changes a canvas's size.

  • Under the default cellSize: "unit" a CSS pixel is a cell, so <canvas width="40" height="10"> is 40×10 cells. With cellSize set they are pixels.
  • Without the attributes, under "unit", width and height default to 38 and 9, HTML's 300×150 pixels over an 8×16 cell, so a bare canvas is not 300 columns wide.
  • Images keep measuring their file's pixels against the terminal's cell under "unit".

The cell grid context

  • Size: it holds the cells its canvas's box holds, reported as cols and rows. CSS width: 40ch; height: 10ch sizes it exactly in any mode. When the box changes size, the cells that still fit keep what was drawn.
  • Typed: importing the package declares getContext("termdom-cellgrid") as returning a CanvasCellGridContext, so no caller casts. canvas is an HTMLCanvasElement, and settings read back as their own types.
  • Behaves as a 2d context does:
    • fillStyle and strokeStyle serialize as a 2d context's. currentcolor is the canvas's color when set, and CanvasText and Canvas, the defaults, are the terminal's own. A translucent fill blends.
    • font is a parsed CSS font shorthand.
    • textAlign (default start) and direction (default inherit); right-to-left text draws in display order.
    • save(), restore(), isContextLost().
    • Coordinates round in every method, getCell included.
    • maxWidth, lineJoin: "bevel", lineStyle: "none", and negative drawImage sizes as a 2d context treats them.
  • Text attributes: textDecoration takes line-through; getCell reports dim, strikethrough and inverse.
  • Only getContext makes a context. new CanvasCellGridContext() and new CanvasRenderingContext2D() throw Illegal constructor, as in browsers.

The 2d context gained the same fixes where it fell short: direction: "inherit" takes the canvas's direction, its right-to-left text draws in display order, and font rejects values the shorthand grammar rejects.

Gauges

A page styles <progress> and <meter> through CSS Form Control Styling (css-forms-1):

  • ::slider-track and ::slider-fill
  • meter:optimal-value, :low-value and :high-value for a meter's levels
  • WebKit's ::-webkit-progress-* and ::-webkit-meter-* and Firefox's ::-moz-progress-bar, ::-moz-meter-bar and :-moz-meter-* as aliases, as its §4.10 asks

accent-color no longer colors a progress bar's fill.

Default actions (#84)

  • Ctrl+Z suspends, as a shell's job control does. The terminal is handed back and the program stops until fg; a document in flow is written to the scrollback and starts again below the shell's lines, and a fullscreen one leaves and re-enters the alternate screen. The document is hidden meanwhile, with visibilitychange on each edge. Transports opt in with an optional suspend(); the process transport leaves raw mode and sends itself SIGTSTP until SIGCONT, except on Windows. Over SSH, Ctrl+Z is an ordinary key. Checked by hand in zsh under tmux on Node, Bun and Deno.
  • The arrows move through a radio group, checking the next radio and wrapping.
  • Left out of Enumerate and specify default actions, i.e. behavior which is prevented with preventDefault() #84: a second Ctrl+C that closes regardless (undecided), and the Tab-wrapping question. Implicit form submission already landed.

Packaging

  • The image decoder is built as a worker entry, src/image-decode.worker.ts, with libuild 0.2.26, and is no longer in exports. The built package starts it and decodes on Bun, Deno and Node 26 with --experimental-web-worker.
  • ColorDepth (24 | 8 | 4 | "rgb" | "256" | "ansi") is exported.

Docs and site

The images guide, the API guide and the styling guide match. The canvas and images examples run on the site.

Tests

New tests cover each change above.

  • A full run on the commit before the last passed on Node and Deno, with one Bun failure in tests/transitions.test.ts. That file fails intermittently on Bun on main too (about 5 in 15 runs). This PR fixes the delayed-transition case, which allowed only 60 ms; the rest are a pre-existing timing flake left for later.
  • The full suite on the final commit was still running when this was opened.

document.close() keeps its scrollback behavior for now but is no longer documented, until document.open(), write() and close() are worked out for the scrollback. window.screenTop stays the row the document's region starts at, the terminal's version of how far down the screen the window is.

🤖 Generated with Claude Code

brainkim and others added 18 commits October 9, 2026 11:25
getContext("termdom-cellgrid") is typed to return a
CanvasCellGridContext, in TermDOM's own canvas and, through a global
declaration, in lib.dom's, so no caller casts. The context's canvas is
an HTMLCanvasElement, and its settings read back as their own values.

columns and rows are gone: canvas.width and canvas.height are the
grid's size, as a 2d context reads its canvas's.

textDecoration takes line-through as well as underline, alone or
together, and getCell reports dim, strikethrough and inverse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
libuild 0.2.26 builds a src/<name>.worker.ts to dist and leaves it out
of exports, which is what a module loaded by file path from
new URL(..., import.meta.url) needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… aliases

A page styles a progress or meter's track and fill through
::slider-track and ::slider-fill, and a meter's level through
:optimal-value, :low-value and :high-value, as CSS Form Control Styling
specifies. WebKit's ::-webkit-progress-* and ::-webkit-meter-* and
Firefox's ::-moz-progress-bar, ::-moz-meter-bar and :-moz-meter-*
pseudo-classes are aliases, as its section 4.10 asks. The groove takes
the track's color, so ::slider-track colors it.

accent-color no longer colors a progress bar's fill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cell grid gains the 2d context's direction: ltr, rtl, or inherit,
the default, which takes the canvas element's direction as the text is
drawn. Right-to-left text is put in display order, as the layout does
for a page's text, and start and end follow it. textAlign defaults to
start, as a 2d context's does.

The 2d context's inherit was read as ltr and its text was written in
logical order; it now resolves and orders the same way.

getCell rounds its coordinates, as every drawing method does, so a
fractional coordinate reads the cell it was drawn to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- fillStyle and strokeStyle read back serialized as a 2d context's do.
  currentcolor is the canvas element's color as it is set, and the
  defaults are CanvasText, the terminal's own text color; Canvas is its
  background. A translucent fill blends over the cell, and text under
  half opaque draws nothing, as a 2d context's text does.
- font is a CSS font shorthand, parsed, serialized and ignored when
  invalid, and its weight and style make text bold, dim or italic. The
  2d context's font validates the same way.
- save(), restore() and isContextLost().
- maxWidth of zero, less or NaN draws nothing.
- lineJoin takes bevel, drawn as a miter, and lineStyle takes none.
- drawImage normalizes a negative destination size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
images.ts reads a file: URL with node:fs's readFileSync, which the
site's sandbox provides, and its broken image is described as the alt
text it shows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A canvas's width and height are unitless, and HTML lays the canvas out
at that many CSS pixels. Under the unit cell a CSS pixel is a cell, so
there they are cells, for a 2d canvas as for a cell grid, and with
cellSize set they are pixels. Asking for a context no longer changes a
canvas's size: a 2d canvas under the unit cell was measured against
the terminal's cell, as an image is, and a cell grid switched to
counting its attributes as cells.

Under the unit cell, width and height default to 38 and 9, the cells
HTML's 300 by 150 pixels fill at 8 by 16 pixels a cell, so a bare
canvas is not 300 columns wide.

A cell grid holds the cells its canvas's box holds, reported as cols
and rows, so CSS sizes it exactly in any mode. When the box changes
size, the cells that still fit keep what was drawn, as a terminal keeps
its screen when its window is resized.

Images keep measuring their pixels against the terminal's cell under
the unit cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page's ::part() does not reach inside a built-in control, as in a
browser, and the demo does not need the colors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d make canvas contexts only through getContext

- colorScheme, what the program knows of the terminal's background, is
  an option of TermDOM beside colorDepth, not a member of
  TerminalTransport or an option of transportFromProcess and
  transportFromSSH.
- A TermDOM disposes through Symbol.asyncDispose, so await using waits
  for the restores as dispose() does. Symbol.dispose is gone: a
  disposal that writes to a terminal cannot promise it is done when it
  returns.
- new CanvasRenderingContext2D() and new CanvasCellGridContext() throw
  Illegal constructor, as a browser's contexts do, so no internal type
  is in their published signatures. A 2d context's pixels a cell before
  layout follow the canvas's size, not an image's.
- A delayed transition's test waits half a second, not 60 ms, so a
  frame that runs late on Bun no longer ends the delay early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
attach() no longer takes a transport to swap in before the first
attach. The constructor's transport does nothing until attach(), and a
transport that is not ready yet resolves ready when it is, so binding
late needed nothing more, and the rebind path served only this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It stays as it is for now, undocumented, until document.open(), write()
and close() are worked out for the scrollback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It made getContext drift from lib.dom's. Importing the package declares
the overload on lib.dom's HTMLCanvasElement, which is where callers'
types come from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
window.close() closes with 1 once the page has let an exception escape,
and 0 otherwise; Ctrl-C's close is an interrupt, 130. A SIGINT, SIGHUP
or SIGTERM sent to the process ends it with 128 plus the signal's
number, where it ended with 0. The hint that a blocked load prints is
not an error and does not change the status.

The API guide said a keydown listener could keep Ctrl-C from closing;
Ctrl-C never reaches keydown, and a beforeunload listener is what keeps
the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… group with the arrows

A keydown nothing canceled scrolls, as in a browser: the arrows a row,
PageUp, PageDown and Space a page, keeping one row of the last in view,
and Home and End to an edge. The nearest scroller around the focus that
can go that way takes the key, and the document otherwise. Keys a field,
a select or editable content takes, and Space on a button, do not
scroll.

The arrows move to the previous or next radio of a group, checking it
and wrapping at the ends.

The git-log, tree, hacker-news, markdown and solitaire examples cancel
the keys they handle, as they would in a browser.

Part of #84.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ctrl+Z hands the terminal back, stops the program, and takes the
terminal again when the shell continues it. A document in flow is
written into the scrollback first, so the shell's lines land below it
and the document starts again under them; a fullscreen one leaves the
alternate screen and comes back to it. The document is hidden while
suspended, with visibilitychange on each edge, as a backgrounded tab
reports. Like Ctrl+C, Ctrl+Z is not a key and cannot be canceled.

A transport suspends through an optional suspend(). The process
transport leaves raw mode and sends itself SIGTSTP, which raw mode
keeps the terminal from sending, and goes on at SIGCONT. Where a
transport has no suspend(), over SSH and on Windows, Ctrl+Z is an
ordinary key.

Part of #84.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brainkim brainkim changed the title 0.2.0 API review: canvas sizing and contexts, gauge styling, colorScheme, disposal 0.2.0 API review: canvas sizing and contexts, gauge styling, colorScheme, disposal, default actions Oct 9, 2026
brainkim and others added 6 commits October 9, 2026 14:33
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
window.setTimeout(), setInterval() and queueMicrotask() hand the
runtime a callback that reports an exception the way a listener's is
reported, through the window's error event and then the transport's
log, and the page goes on, as in a browser. They were the runtime's own,
whose exceptions ended the process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… rejections while installed

installGlobals() puts each of the window's names on globalThis, the
runtime's included, so the process reads as the window does: a bare
setTimeout() reports what it lets escape, and navigator, localStorage
and, on Bun and Deno, addEventListener and alert are the window's.
Uninstalling puts back each name's own descriptor. The runtime's event
classes stay its own: on Bun and Deno its event targets accept only its
own events, and the document accepts them as well.

While installed, a promise rejected with no handler fires
unhandledrejection on the window, as a PromiseRejectionEvent, and one
no listener cancels is reported as an uncaught exception, where the
runtime would end the process. Deno fires its own event at the window
first; a promise the window has heard about is not fired again.

The window's timers call the runtime's own, kept from when the module
loaded, so they do not call themselves once installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A browser's setTimeout, queueMicrotask and the rest throw Illegal
invocation when called on anything but the global, and the window's
wrappers called them on the object that kept them, so the site's
sandbox broke for the canvas and CodeMirror examples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ls()

Where the runtime defines them, they stay its own, as its event classes
do: Deno's, and Node's given --localstorage-file, keep what is stored
between runs, where the window's keep it in memory. On Bun and Node 20,
which have none, the window's are installed. Only whether the name is
there is asked, since reading Node's when it has no storage file prints
an ExperimentalWarning, which installing the globals did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brainkim
brainkim merged commit 97ebfe8 into main Oct 9, 2026
13 of 14 checks passed
@brainkim
brainkim deleted the api-review branch October 9, 2026 19:42
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