Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dc6c994
Type the cell grid context and round out its text attributes
brainkim Oct 9, 2026
5315782
Build the image decoder as a worker entry, out of the package's exports
brainkim Oct 9, 2026
e6fe843
Style gauges through css-forms-1's pseudo-elements and their prefixed…
brainkim Oct 9, 2026
989f038
Give both canvas contexts a direction, and round the cell grid's getCell
brainkim Oct 9, 2026
d722419
Make the cell grid's settings behave as a 2d context's
brainkim Oct 9, 2026
b462d96
Run the canvas and images examples on the site
brainkim Oct 9, 2026
df1775e
Say that a cell grid canvas wants its width and height set
brainkim Oct 9, 2026
5552bf6
Lay a canvas out at width by height CSS pixels whatever its context
brainkim Oct 9, 2026
d5d7f7d
Export ColorDepth, the numbers and names colorDepth takes
brainkim Oct 9, 2026
34c75d3
Take the progress bar's ::part styling out of the launch post's demo
brainkim Oct 9, 2026
b894232
Take colorScheme as a TermDOM option, dispose asynchronously only, an…
brainkim Oct 9, 2026
b9d70ac
Take the transport only from the constructor
brainkim Oct 9, 2026
34f1334
Leave document.close()'s scrollback behavior out of the guides
brainkim Oct 9, 2026
b016ef8
Leave the cell grid overload off the internal canvas class
brainkim Oct 9, 2026
fc3f089
Exit with the status a shell expects
brainkim Oct 9, 2026
4f9ad6b
Scroll with the keys a browser scrolls with, and move through a radio…
brainkim Oct 9, 2026
8d18b5d
Suspend on Ctrl+Z, as a shell's job control does
brainkim Oct 9, 2026
1cd951e
Document the scrolling keys, radio arrows, Ctrl+Z and suspend()
brainkim Oct 9, 2026
e1b02fc
Fold a long line in the hacker-news example
brainkim Oct 9, 2026
2863e1e
Report an exception a window timer or microtask lets escape
brainkim Oct 9, 2026
ff0fbb2
Install the window's globals over the runtime's, and report unhandled…
brainkim Oct 9, 2026
50d03c8
Bind the runtime's timers the window wraps to the global
brainkim Oct 9, 2026
855d451
Say what a server holding many sessions owns itself
brainkim Oct 9, 2026
33ea68e
Keep the runtime's localStorage and sessionStorage under installGloba…
brainkim Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ document.body.innerHTML = `
<style>
.card { border: 1px solid #5fafff; padding: 0 1ch; width: 36ch; }
.title { color: #5fafff; font-weight: bold; }
progress { width: 25ch; accent-color: green; }
progress { width: 25ch; }
progress::slider-fill { color: green; }
.pct { color: #888; }
</style>
<div class="card">
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 0 additions & 6 deletions docs/guides/02-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,6 @@ region and prints the whole document once more as plain output, so the
final state sits in the scrollback like the output of any other command,
and the shell prompt returns below it.

`document.close()` does the same thing without ending the program: the
document is written into the scrollback and sealed. The next mutation
starts a fresh document below it, on a new starting row. A program that
prints a sequence of reports, one after another, can build each as a
document and close it.

### Resizing

When the terminal is resized, the engine asks for the cursor position
Expand Down
6 changes: 4 additions & 2 deletions docs/guides/04-styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,10 @@ ranges in `CSS.highlights`. `::placeholder` styles a text control's
placeholder. `::backdrop` styles what a modal dialog or popover covers.
A `<select>`'s picker and icon take `::picker(select)` and
`::picker-icon`, and a `<details>`' content takes `::details-content`.
A `<progress>` fills with its `accent-color`. `::part()` from a page
does not reach inside the built-in controls, as in a browser.
A `<progress>` or `<meter>` takes `::slider-track` and `::slider-fill`,
and a meter's levels match `:optimal-value`, `:low-value` and
`:high-value`. `::part()` from a page does not reach inside the
built-in controls, as in a browser.

## Default looks

Expand Down
31 changes: 27 additions & 4 deletions docs/guides/05-events-and-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,15 @@ byte. These limits apply to every terminal application:
second action instead.
- `Enter` is the byte for `Ctrl+M` and `Tab` for `Ctrl+I`; the named key
is what you get, and those chords never arrive.
- `Ctrl+C` is the interrupt; it never reaches the document.
- `Ctrl+C` is the interrupt; it never reaches the document. It calls
`window.close()`, and a `beforeunload` listener that calls
`preventDefault()` keeps the session.
- `Ctrl+Z` suspends the program, as a shell's job control does, and
never reaches the document either. The terminal is handed back and
`document.visibilityState` is `"hidden"` until the shell continues the
program with `fg`; a document in flow starts again below the shell's
lines. Over SSH, and on Windows, where nothing can suspend the program,
`Ctrl+Z` is an ordinary key.
- `Ctrl+L` redraws the screen, as it does in a shell, unless a `keydown`
listener calls `preventDefault()`.
- `Ctrl+Shift+letter` is indistinguishable from `Ctrl+letter`.
Expand Down Expand Up @@ -156,7 +164,8 @@ field.addEventListener("input", updatePreview);
`::placeholder` styles a field's placeholder. The caret is the real terminal cursor, and IME composition
works: CJK input methods compose in the field. `<input type="password">`
masks its value. A `<textarea>` scrolls to keep its caret in view. A number input takes float syntax only, and ArrowUp and
ArrowDown step it within `min` and `max`.
ArrowDown step it within `min` and `max`. The arrows move a radio's
check to the previous or next radio of its group, wrapping at the ends.

`new FormData(form)` builds the form's entry list: every submittable
control it owns, in tree order, by the rule its kind has. Submitting a
Expand Down Expand Up @@ -219,8 +228,13 @@ spellcheck.
## Errors

An exception that escapes a listener, an event handler attribute, an
observer callback, or a frame callback is reported as a browser reports
it. The window hears a cancelable `error` event first:
observer callback, a frame callback, or a callback given to
`window.setTimeout()`, `setInterval()` or `queueMicrotask()` is reported
as a browser reports it. A bare `setTimeout()` is the runtime's own
unless `installGlobals()` has put the window's in its place, and an
exception from the runtime's ends the program. With the globals
installed, a promise rejected with no handler fires
`unhandledrejection` on the window and is reported the same way. The window hears a cancelable `error` event first:

```ts
window.addEventListener("error", (event) => {
Expand Down Expand Up @@ -297,6 +311,15 @@ pane.scrollTop += 5;
element.scrollIntoView();
```

The keys scroll as a browser's do, as the default action of a `keydown`
no listener canceled: ArrowUp and ArrowDown 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 box around the focused element that can scroll
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. A program that uses those keys itself calls `preventDefault()`,
as it would in a browser.

`scroll` fires on the box, or on the document for the document scroll,
and `scrollend` follows once scrolling stops. `window.scrollY` reports
the document position, clamped to the end as soon as it is set.
Expand Down
139 changes: 83 additions & 56 deletions docs/guides/06-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,15 @@ Options:
(default) uses the best way the terminal has, `"cells"` draws them two
to a cell in half blocks, and `"none"` draws none: images show their `alt` text and
`getContext("2d")` returns `null`.
- `colorDepth?: "auto" | 24 | 8 | 4` — the colors the terminal shows,
in the bits `screen.colorDepth` reports. With `"auto"` (default),
TermDOM asks the terminal. `"rgb"`, `"256"` and `"ansi"` name 24, 8
and 4. Any other value throws a `TypeError`. See "Color depth" in the
- `colorDepth?: "auto" | ColorDepth` — the colors the terminal shows:
`24`, `8` or `4`, the bits `screen.colorDepth` reports, or `"rgb"`,
`"256"` or `"ansi"`, the same three by name. With `"auto"` (default),
TermDOM asks the terminal. Any other value throws a `TypeError`. See "Color depth" in the
styling guide.
- `colorScheme?: "light" | "dark"` — whether the terminal's background
is light or dark, where the program knows. It holds until the
terminal answers TermDOM's question about its background, and stands
when the terminal gives no answer. Light without either.

TermDOM reads no environment variables. What the terminal can say, it is
asked, and the rest is an option.
Expand All @@ -81,8 +85,6 @@ asked, and the rest is an option.

`document` is a `Document`. Setting `document.title` sets the terminal
window title; the previous title is restored on `dispose()`.
`document.close()` writes the document into the scrollback and seals
it, and the next mutation starts a fresh document below it.

`window` has the DOM interfaces and event constructors, and these members
are wired to the terminal:
Expand All @@ -107,10 +109,15 @@ are wired to the terminal:
- `navigator.userActivation` — `hasBeenActive` and `isActive`
- `MutationObserver`, `ResizeObserver`, `IntersectionObserver` — entries
are delivered per rendered frame
- `close()` — quit: flush the final frame to scrollback, restore terminal
modes, dispose, and call `transport.close({status: 0})`, which exits the
process on the default transport. Ctrl-C calls this as its default
action; a `keydown` listener that calls `preventDefault()` overrides it.
- `close()` — quit: fire `beforeunload`, then flush the final frame to
scrollback, restore terminal modes, dispose, and call
`transport.close({status})`, which exits the process on the default
transport. A `beforeunload` listener that calls `preventDefault()`
keeps the session. Ctrl-C calls `close()`; it does not reach `keydown`.
The status is 130 when Ctrl-C closed the window, as a shell reports an
interrupt, 1 when the page let an exception escape, and 0 otherwise.
On the default transport, a `SIGINT`, `SIGHUP` or `SIGTERM` sent to the
process ends it with 130, 129 or 143.

Anything not listed behaves as the DOM and CSSOM standards specify,
without terminal wiring.
Expand All @@ -125,6 +132,7 @@ pseudo-elements browsers give them:
| `<input>` text types, `<textarea>` | `::placeholder` |
| `<select>` | `::picker(select)`, `::picker-icon` |
| `<details>` | `::details-content` |
| `<progress>`, `<meter>` | `::slider-track`, `::slider-fill` |

`::picker(select)` and `::picker-icon` are a customizable `<select>`'s,
as Chromium ships them.
Expand All @@ -134,14 +142,27 @@ select::picker(select) { background-color: #1c1c1c; }
select::picker-icon { color: gray; }
```

A `<progress>` draws its fill in its `accent-color`, as browsers do.
`::slider-track` and `::slider-fill` are CSS Form Control Styling's
(css-forms-1). The track holds the fill and shows past it. A meter
matches `:optimal-value` when its value is in its optimum region,
`:low-value` below `low`, and `:high-value` above `high`.

```css
progress { accent-color: green; }
progress::slider-fill { color: green; }
meter:low-value::slider-fill { color: orange; }
```

The rest of a control's insides, such as a meter's fill or a button's
brackets, are TermDOM's own, as they are a browser's: the
The prefixed names WebKit and Firefox ship work too, as aliases:
`::-webkit-progress-bar` and `::-webkit-meter-bar` are the track,
`::-webkit-progress-value`, `::-moz-progress-bar` and `::-moz-meter-bar`
the fill, `::-webkit-meter-optimum-value`,
`::-webkit-meter-suboptimum-value` and
`::-webkit-meter-even-less-good-value` a meter's fill at each level,
and `:-moz-meter-optimum`, `:-moz-meter-sub-optimum` and
`:-moz-meter-sub-sub-optimum` the meter at each level.

The rest of a control's insides, such as a button's brackets, are
TermDOM's own, as they are a browser's: the
controls are shadow trees inside, and `::part()` does not reach into
them. TermDOM's own rules come first, so a page's `::placeholder` and the
rest override them as they would any built-in style.
Expand All @@ -164,7 +185,7 @@ term.addEventListener("fetch", (event) => {
A load `csp` blocks fires `securitypolicyviolation` at its element
instead. The images guide has both.

### `term.attach(transport?)`
### `term.attach()`

Puts the terminal in raw mode, starts input handling, mouse reporting,
and bracketed paste, and paints whatever the document holds. Idempotent;
Expand All @@ -179,9 +200,6 @@ until `dispose()`, and `"hidden"` after. It is also `"hidden"` while the
wheel has been handed to the terminal's scrollback, until the next
keystroke. `visibilitychange` fires on each change.

Passing a transport rebinds the instance to it, only before the first
attach.

### `term.renderANSI(html?)`

Returns the whole document as an ANSI string at the terminal's width:
Expand All @@ -208,17 +226,19 @@ continues; `window.close()` is the quit. Returns a promise that resolves
when every queued restore has reached the transport; await it before
writing further output. The process transport also restores
shell-critical modes synchronously, so a caller that exits without
awaiting still leaves the shell usable. `using term = new TermDOM()`
disposes on scope exit.
awaiting still leaves the shell usable. `await using term = new TermDOM()`
disposes on scope exit and waits for it.

## `installGlobals(term)`

Defines the window's names on `globalThis`, for code that reads
`document`, `window`, `Element`, `getSelection` and the like as globals:
React, Vue, Svelte, CodeMirror. Only names the runtime lacks are
defined, so Node's `Event`, `fetch` and `navigator` stay Node's, and in a
browser nothing changes. Methods are bound to the window, and other
properties read and write through to it, so `scrollY` stays current.
React, Vue, Svelte, CodeMirror. The process then reads as the window
does. A name the runtime defines too, such as `setTimeout`,
`navigator` or, on Bun and Deno, `addEventListener` and `alert`, is the
window's until uninstalled. Methods are bound to the
window, and other properties read and write through to it, so `scrollY`
stays current.

```ts
import {installGlobals, TermDOM} from "@b9g/termdom";
Expand All @@ -227,29 +247,26 @@ const term = new TermDOM();
const uninstall = installGlobals(term);
```

It returns a function that removes what it defined. One TermDOM's
It returns a function that puts back what was there. One TermDOM's
globals can be installed at a time; calling it again before uninstalling
throws.

Some runtimes have window-like globals of their own, and those stay the
runtime's. A bare call to one of these reaches the runtime, not the
TermDOM, so write `window.` before it:

| Name | Runtime that has its own |
| --- | --- |
| `addEventListener`, `removeEventListener`, `dispatchEvent` | Bun, Deno |
| `postMessage` | Bun |
| `close` (it exits Deno) | Deno |
| `location`, `name`, `closed` | Deno |
| `alert`, `confirm`, `prompt`, `self`, `onerror` | Bun, Deno |
| `localStorage`, `sessionStorage` | Node, Deno |

Events made with the runtime's `Event` or `CustomEvent` reach the
document's listeners, and the document's events are instances of the
runtime's `Event`. What a terminal does not have, such as `indexedDB`
or `caches`, is not installed, so `typeof indexedDB` is `"undefined"`
and feature detection takes its fallback. Uninstalling leaves any name
that other code has redefined since.
The runtime's event classes, `Event`, `EventTarget`, `CustomEvent`,
`ErrorEvent` and `MessageEvent`, stay its own: on Bun and Deno the
runtime's own event targets accept only its own events, and the
document accepts them too, so they serve both. So do `localStorage` and
`sessionStorage` where the runtime has them: 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. What a terminal does not
have, such as `indexedDB` or `caches`, is not installed, so feature
detection takes its fallback.

While installed, a promise rejected with no handler is the page's: it
fires `unhandledrejection` on the window, and one no listener cancels is
reported as an uncaught exception is, where the runtime would end the
process. Uninstalling leaves any name that other code has redefined
since.

## `TerminalTransport`

Expand All @@ -262,9 +279,6 @@ interface TerminalTransport {
readonly cols: number; // live: always the current size
readonly rows: number;
readonly interactive: boolean; // false: plain line output (a pipe)
// Optional. Whether the terminal's background is dark, where the
// program knows. The terminal's own answer replaces it.
readonly colorScheme?: "light" | "dark";
// Optional. Takes an error's text somewhere the frame does not share;
// true when it did. The engine keeps what it cannot place and prints
// it below the document at the end.
Expand All @@ -278,6 +292,10 @@ interface TerminalTransport {
readonly resizes: ReadableStream<{cols: number; rows: number}>;
readonly ready: Promise<void>; // established; Promise.resolve() if born so
readonly closed: Promise<TerminalCloseInfo>; // the terminal went away
// Optional. Stops the program as a shell's job control does, and
// resolves when it continues; the engine hands the terminal back before
// and takes it again after. Without it, Ctrl+Z is an ordinary key.
suspend?(): Promise<void>;
// Ends the medium if the transport owns it (the process transport exits
// the process); a no-op otherwise.
close(info?: TerminalCloseInfo): void;
Expand Down Expand Up @@ -309,13 +327,13 @@ const term = new TermDOM({transport: transportFromProcess(process)});
- `options.sharesScreen` — overrides `sharesScreen`, which defaults to
true for the global process (it sits below a shell) and false for
anything else.
- `options.colorScheme` — `"light"` or `"dark"`, for the frames before
the terminal reports its background.

The wrapper owns all process-level behavior: raw mode, `SIGWINCH` →
`resizes`, signals → `closed`, `stdout.isTTY` → `interactive`, `stderr`
when it is not a terminal → `logError`, and an exit hook that restores
the cursor if the app exits without disposing.
when it is not a terminal → `logError`, `suspend()` by leaving raw
mode and sending the process `SIGTSTP` until `SIGCONT` (not on Windows),
and an exit hook that restores the cursor if the app exits without
disposing.

## `transportFromSSH(session, options?)`

Expand Down Expand Up @@ -344,7 +362,6 @@ new ssh2.Server({hostKeys: [hostKey]}, (client) => {
does not depend on ssh2.
- `options.sharesScreen` — defaults to true: the client's shell is above
the `ssh` command, so the document anchors below it.
- `options.colorScheme` — as for `transportFromProcess`.

The transport accepts the session's pty request and takes its size, or
80×24 without one. It accepts the shell request, and `ready` resolves
Expand All @@ -361,6 +378,19 @@ To serve an app to real users, an OpenSSH `Match` block with
[`examples/ssh.ts`](https://github.com/bikeshaving/termdom/blob/main/examples/ssh.ts)
is a whole server.

A server that holds many sessions in one process gives each its own
`TermDOM`, and an exception a session's listeners, timers or frames let
escape is reported to that session's window and log. Two things stay
the server's, because the process has only one of each:

- Globals. `installGlobals()` puts one window on `globalThis`, so a
server passes each session's `document` to its framework instead, as
React's `createRoot(term.document.body)` takes it.
- Promise rejections no one handles. The runtime decides what one does,
and Node ends the process, with every session in it. A server that
should outlive one session's mistake listens for them itself, with
`process.on("unhandledRejection", ...)`.

## Other exports

`TermDOM`, `installGlobals`, `transportFromProcess` and
Expand All @@ -370,9 +400,6 @@ is a whole server.
`"fetch"` event.
- `CanvasCellGridContext`, the class behind a canvas's
`"termdom-cellgrid"` context.
- The types `TermDOMOptions`, `TermDOMEventMap`, `CellSize`,
- The types `TermDOMOptions`, `TermDOMEventMap`, `CellSize`, `ColorDepth`,
`TerminalTransport`, `TerminalCloseInfo`, `TerminalSize`, `ProcessLike`,
`TTYReadStream`, `TTYWriteStream` and `SSHSessionLike`.

`@b9g/termdom/decode-worker` is the module that decodes images on a
`Worker`. Programs do not import it.
Loading
Loading