From dc6c994038631cb4395c2e008e65019fac25bbb5 Mon Sep 17 00:00:00 2001
From: Brian Kim
Date: Fri, 9 Oct 2026 11:25:13 -0400
Subject: [PATCH 01/24] Type the cell grid context and round out its text
attributes
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
---
docs/guides/07-images-and-canvas.md | 11 ++---
examples/canvas.ts | 2 +-
src/index.ts | 10 +++++
src/internal/canvas.ts | 54 ++++++++++++++--------
src/internal/dom.ts | 8 +++-
src/internal/framebuffer.ts | 4 ++
tests/canvas.test.ts | 69 ++++++++++++++++++++---------
7 files changed, 110 insertions(+), 48 deletions(-)
diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md
index 83da6b29d..70a39d84b 100644
--- a/docs/guides/07-images-and-canvas.md
+++ b/docs/guides/07-images-and-canvas.md
@@ -287,13 +287,14 @@ The cell grid context draws cells instead of pixels. The canvas is
TermDOM's own, so its name carries TermDOM's prefix, as a browser's own
context types once carried theirs (`"moz-webgl"`, `"webkit-3d"`). Its
interface is not on `window`. The `@b9g/termdom` package exports it as
-`CanvasCellGridContext`.
+`CanvasCellGridContext`, and importing the package teaches TypeScript
+that `getContext("termdom-cellgrid")` returns one.
```ts
const canvas = document.createElement("canvas");
canvas.width = 20;
canvas.height = 3;
-const grid = canvas.getContext("termdom-cellgrid");
+const grid = canvas.getContext("termdom-cellgrid")!;
grid.fillStyle = "#003366";
grid.fillRect(0, 0, 20, 3);
grid.fillStyle = "#ffcc00";
@@ -317,16 +318,16 @@ not the page's borders around the canvas.
| `lineJoin` | `miter`, the default, or `round`, which curves `strokeRect()`'s corners |
| `font` | Keywords from the CSS `font` shorthand: `bold`, `italic`, `lighter` |
| `textAlign` | `left`, `right`, `center`, `start` or `end` |
-| `textDecoration` | `none` or `underline` |
+| `textDecoration` | `none`, or `underline` and `line-through`, alone or together |
| `fillText(text, x, y, maxWidth?)` | Writes glyphs in the fill color, over the cells' backgrounds. A wide glyph takes two columns |
| `fillRect(x, y, w, h)` | Fills cells with the fill color as their background. `currentcolor` fills them in inverse video |
| `clearRect(x, y, w, h)` | Empties cells |
| `strokeLine(x1, y1, x2, y2)` | A line from one cell to another, both included, along a row or a column. A slanted line draws nothing |
| `strokeRect(x, y, w, h)` | A box around the cells `w` by `h` from (x, y) |
| `drawImage(image, ...)` | Draws an image's pixels, two to a cell. Without a size, the image takes its natural size in cells |
-| `getCell(x, y)` | The cell's glyph, colors and attributes, or `null` |
+| `getCell(x, y)` | The cell's `char`, `color` and `background`, and whether it is `bold`, `italic`, `dim`, `underline`, `strikethrough` or `inverse`; `null` for an empty cell |
| `measureText(text)` | The text's width in columns |
-| `columns`, `rows`, `reset()` | The grid's size, and clearing it |
+| `reset()` | Clears the grid and the settings |
## Text alone
diff --git a/examples/canvas.ts b/examples/canvas.ts
index 3c15667a8..eb0eb3f3a 100644
--- a/examples/canvas.ts
+++ b/examples/canvas.ts
@@ -37,7 +37,7 @@ pixels.style.height = "16px";
const ctx = pixels.getContext("2d")!;
const grid = (document.getElementById("cells") as HTMLCanvasElement)
- .getContext("termdom-cellgrid" as "2d") as any;
+ .getContext("termdom-cellgrid")!;
// Conway's Life on the grid, seeded with a glider, blinkers, a toad and
// an R-pentomino, which keeps changing for a long time.
diff --git a/src/index.ts b/src/index.ts
index 84c0ee5cb..587c3b607 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,5 +1,6 @@
import "./internal/inspector.ts";
+import type {CanvasCellGridContext} from "./internal/canvas.ts";
import {
Cascade,
type CellSize,
@@ -79,6 +80,15 @@ export type {
TerminalTransport,
} from "./internal/exchange.ts";
+declare global {
+ interface HTMLCanvasElement {
+ getContext(
+ contextId: "termdom-cellgrid",
+ options?: unknown,
+ ): CanvasCellGridContext | null;
+ }
+}
+
// Images decode on the runtime's web Worker, running src/decode-worker.ts,
// which builds to dist/decode-worker.js beside this file. A runtime with
// no Worker, as Node is without --experimental-web-worker, and a CommonJS
diff --git a/src/internal/canvas.ts b/src/internal/canvas.ts
index ddf15fa3b..82158a267 100644
--- a/src/internal/canvas.ts
+++ b/src/internal/canvas.ts
@@ -2970,7 +2970,7 @@ export interface CanvasCellGridContext {
[kLineStyle]: LineStyle["style"];
[kLineJoin]: "miter" | "round";
[kFont]: string;
- [kTextAlign]: string;
+ [kTextAlign]: "start" | "end" | "left" | "right" | "center";
[kTextDecoration]: string;
}
@@ -2995,16 +2995,8 @@ export class CanvasCellGridContext {
this[kTextDecoration] = "none";
}
- get canvas(): object {
- return this[kGridHost].canvas;
- }
-
- get columns(): number {
- return this[kGridHost].cells().cols;
- }
-
- get rows(): number {
- return this[kGridHost].cells().rows;
+ get canvas(): HTMLCanvasElement {
+ return this[kGridHost].canvas as HTMLCanvasElement;
}
get fillStyle(): string {
@@ -3024,7 +3016,7 @@ export class CanvasCellGridContext {
}
/** A CSS border style: solid, double, dashed, dotted and the rest. */
- get lineStyle(): string {
+ get lineStyle(): LineStyle["style"] {
return this[kLineStyle];
}
@@ -3036,7 +3028,7 @@ export class CanvasCellGridContext {
}
/** miter, round. A round join curves strokeRect()'s corners. */
- get lineJoin(): string {
+ get lineJoin(): "miter" | "round" {
return this[kLineJoin];
}
@@ -3055,24 +3047,41 @@ export class CanvasCellGridContext {
this[kFont] = String(value);
}
- get textAlign(): string {
+ get textAlign(): "start" | "end" | "left" | "right" | "center" {
return this[kTextAlign];
}
set textAlign(value: string) {
- if (["start", "end", "left", "right", "center"].includes(value)) {
+ if (
+ value === "start" ||
+ value === "end" ||
+ value === "left" ||
+ value === "right" ||
+ value === "center"
+ ) {
this[kTextAlign] = value;
}
}
- /** none, underline. */
+ /** none, or underline and line-through, alone or together. */
get textDecoration(): string {
return this[kTextDecoration];
}
set textDecoration(value: string) {
- if (value === "none" || value === "underline") {
- this[kTextDecoration] = value;
+ const words = String(value).trim().toLowerCase().split(/\s+/);
+ if (words.length === 1 && words[0] === "none") {
+ this[kTextDecoration] = "none";
+ return;
+ }
+ const lines = new Set(words);
+ if (
+ lines.size === words.length &&
+ words.every((word) => word === "underline" || word === "line-through")
+ ) {
+ this[kTextDecoration] = ["underline", "line-through"]
+ .filter((line) => lines.has(line))
+ .join(" ");
}
}
@@ -3137,7 +3146,8 @@ export class CanvasCellGridContext {
bold: words.includes("bold") || words.includes("bolder"),
italic: words.includes("italic") || words.includes("oblique"),
dim: words.includes("lighter"),
- underline: this[kTextDecoration] === "underline",
+ underline: this[kTextDecoration].includes("underline"),
+ strikethrough: this[kTextDecoration].includes("line-through"),
};
const {grid} = cells;
for (const cluster of clusters) {
@@ -3320,7 +3330,10 @@ export class CanvasCellGridContext {
background: string | null;
bold: boolean;
italic: boolean;
+ dim: boolean;
underline: boolean;
+ strikethrough: boolean;
+ inverse: boolean;
} | null {
const {grid, cols, rows} = this[kGridHost].cells();
const col = Math.floor(x);
@@ -3350,7 +3363,10 @@ export class CanvasCellGridContext {
background: css(cell.bg),
bold: cell.bold,
italic: cell.italic,
+ dim: cell.dim,
underline: cell.underline,
+ strikethrough: cell.strikethrough,
+ inverse: cell.inverse,
};
}
diff --git a/src/internal/dom.ts b/src/internal/dom.ts
index de6319ba1..0b5e80e19 100644
--- a/src/internal/dom.ts
+++ b/src/internal/dom.ts
@@ -14486,6 +14486,10 @@ class HTMLCanvasElement extends HTMLElement {
contextId: "2d",
options?: globalThis.CanvasRenderingContext2DSettings,
): globalThis.CanvasRenderingContext2D | null;
+ getContext(
+ contextId: "termdom-cellgrid",
+ options?: unknown,
+ ): CanvasCellGridContext | null;
getContext(
contextId: "bitmaprenderer",
options?: globalThis.ImageBitmapRenderingContextSettings,
@@ -14502,7 +14506,9 @@ class HTMLCanvasElement extends HTMLElement {
contextId: string,
options?: any,
): globalThis.RenderingContext | null;
- getContext(contextId: string): globalThis.RenderingContext | null {
+ getContext(
+ contextId: string,
+ ): globalThis.RenderingContext | CanvasCellGridContext | null {
if (arguments.length < 1) {
throw new TypeError("getContext needs a context id");
}
diff --git a/src/internal/framebuffer.ts b/src/internal/framebuffer.ts
index e88d4f921..136f25a5d 100644
--- a/src/internal/framebuffer.ts
+++ b/src/internal/framebuffer.ts
@@ -948,6 +948,8 @@ export function readCell(
bold: boolean;
italic: boolean;
underline: boolean;
+ strikethrough: boolean;
+ dim: boolean;
inverse: boolean;
} | null {
if (grid.cluster[index] === 0) {
@@ -964,6 +966,8 @@ export function readCell(
bold: (attrs & ATTR.Bold) !== 0,
italic: (attrs & ATTR.Italic) !== 0,
underline: (attrs & (ATTR.Underline | ATTR.DoubleUnderline)) !== 0,
+ strikethrough: (attrs & ATTR.Strikethrough) !== 0,
+ dim: (attrs & ATTR.Dim) !== 0,
inverse: (attrs & ATTR.Inverse) !== 0,
};
}
diff --git a/tests/canvas.test.ts b/tests/canvas.test.ts
index 67a8720bd..3fd0c05a2 100644
--- a/tests/canvas.test.ts
+++ b/tests/canvas.test.ts
@@ -77,10 +77,10 @@ test("getContext hands back one context per canvas, and null for the rest", asyn
expect(ctx).not.toBeNull();
expect(a.getContext("2d")).toBe(ctx);
expect(ctx!.canvas).toBe(a);
- expect(a.getContext("termdom-cellgrid" as "2d")).toBeNull();
+ expect(a.getContext("termdom-cellgrid")).toBeNull();
expect(a.getContext("webgl")).toBeNull();
expect(b.getContext("bitmaprenderer")).toBeNull();
- const grid = b.getContext("termdom-cellgrid" as "2d");
+ const grid = b.getContext("termdom-cellgrid");
expect(grid).not.toBeNull();
expect(b.getContext("2d")).toBeNull();
expect(ctx).toBeInstanceOf((dom.window as any).CanvasRenderingContext2D);
@@ -600,9 +600,9 @@ test("a canvas stretched by CSS scales its pixels to the box", async () => {
// ---------------------------------------------------------------------------
// cellgrid
-function grid(document: Document, id = "g"): any {
+function grid(document: Document, id = "g"): CanvasCellGridContext {
return (document.getElementById(id) as HTMLCanvasElement)
- .getContext("termdom-cellgrid" as "2d");
+ .getContext("termdom-cellgrid")!;
}
test("a cellgrid canvas is width columns by height rows of cells", async () => {
@@ -612,7 +612,7 @@ test("a cellgrid canvas is width columns by height rows of cells", async () => {
await nextFrame(dom);
const rect = document.getElementById("g")!.getBoundingClientRect();
expect([rect.width, rect.height]).toEqual([12, 3]);
- expect([ctx.columns, ctx.rows]).toEqual([12, 3]);
+ expect([ctx.canvas.width, ctx.canvas.height]).toEqual([12, 3]);
dom.dispose();
});
@@ -645,7 +645,10 @@ test("fillText writes glyphs, fillRect fills backgrounds, clearRect empties", as
background: "#003366",
bold: true,
italic: true,
+ dim: false,
underline: false,
+ strikethrough: false,
+ inverse: false,
});
ctx.clearRect(0, 0, 12, 1);
expect(ctx.getCell(1, 0)).toBeNull();
@@ -655,6 +658,34 @@ test("fillText writes glyphs, fillRect fills backgrounds, clearRect empties", as
dom.dispose();
});
+test("textDecoration draws underline and line-through, and getCell reads them back", async () => {
+ const {dom, document} =
+ await mount("");
+ const ctx = grid(document);
+ ctx.textDecoration = "line-through underline";
+ expect(ctx.textDecoration).toBe("underline line-through");
+ ctx.textDecoration = "overline";
+ expect(ctx.textDecoration).toBe("underline line-through");
+ ctx.font = "lighter";
+ ctx.fillText("ab", 0, 0);
+ ctx.textDecoration = "line-through";
+ ctx.font = "";
+ ctx.fillText("c", 2, 0);
+ expect(ctx.getCell(0, 0)).toMatchObject({
+ dim: true,
+ underline: true,
+ strikethrough: true,
+ });
+ expect(ctx.getCell(2, 0)).toMatchObject({
+ dim: false,
+ underline: false,
+ strikethrough: true,
+ });
+ ctx.textDecoration = "none";
+ expect(ctx.textDecoration).toBe("none");
+ dom.dispose();
+});
+
test("text that runs past the grid is cut, and wide glyphs take two columns", async () => {
const {dom, terminal, document} =
await mount("|");
@@ -662,11 +693,11 @@ test("text that runs past the grid is cut, and wide glyphs take two columns", as
ctx.fillText("漢字abcdef", 0, 0);
await nextFrame(dom);
expect(rowText(terminal, 0)).toBe("漢字ab|");
- expect(ctx.getCell(0, 0).char).toBe("漢");
+ expect(ctx.getCell(0, 0)!.char).toBe("漢");
expect(ctx.measureText("漢字a").width).toBe(5);
ctx.textAlign = "right";
ctx.fillText("Z", 6, 0);
- expect(ctx.getCell(5, 0).char).toBe("Z");
+ expect(ctx.getCell(5, 0)!.char).toBe("Z");
dom.dispose();
});
@@ -715,7 +746,7 @@ test("resizing a cellgrid canvas resizes and clears its grid", async () => {
const ctx = grid(document);
ctx.fillText("abcd", 0, 0);
canvas.width = 8;
- expect(ctx.columns).toBe(8);
+ expect(ctx.canvas.width).toBe(8);
expect(ctx.getCell(0, 0)).toBeNull();
await nextFrame(dom);
expect(canvas.getBoundingClientRect().width).toBe(8);
@@ -867,7 +898,7 @@ test("strokeRect draws a box and strokeLine joins it as tees", async () => {
expect(rowText(terminal, 1)).toBe("│ │ │");
expect(rowText(terminal, 2)).toBe("├──┴─┤");
expect(rowText(terminal, 3)).toBe("└────┘");
- expect(ctx.getCell(3, 2).char).toBe("┴");
+ expect(ctx.getCell(3, 2)!.char).toBe("┴");
dom.dispose();
});
@@ -935,9 +966,9 @@ test("a cellgrid samples only the cells an image lands on, however large", async
source.fillRect(1, 0, 1, 2);
const ctx = grid(document);
ctx.drawImage(document.getElementById("src"), -1e6 + 2, 0, 2e6, 1e6);
- expect(ctx.getCell(1, 0).background).toBe("#ff0000");
- expect(ctx.getCell(2, 0).background).toBe("#0000ff");
- expect(ctx.getCell(3, 1).background).toBe("#0000ff");
+ expect(ctx.getCell(1, 0)!.background).toBe("#ff0000");
+ expect(ctx.getCell(2, 0)!.background).toBe("#0000ff");
+ expect(ctx.getCell(3, 1)!.background).toBe("#0000ff");
dom.dispose();
});
@@ -975,7 +1006,7 @@ test("with graphics off, a canvas has no 2d context and keeps its cellgrid", asy
const b = dom.document.createElement("canvas");
dom.document.body.append(a, b);
expect(a.getContext("2d")).toBeNull();
- expect(b.getContext("termdom-cellgrid" as "2d")).toBeInstanceOf(
+ expect(b.getContext("termdom-cellgrid")).toBeInstanceOf(
CanvasCellGridContext,
);
dom.dispose();
@@ -1014,9 +1045,7 @@ test("a cell grid clips to a scroller and paints the rows scrolled into view", a
"
below
",
);
const canvas = document.getElementById("c") as HTMLCanvasElement;
- const grid = canvas.getContext("termdom-cellgrid" as "2d") as unknown as {
- fillText(text: string, x: number, y: number): void;
- };
+ const grid = canvas.getContext("termdom-cellgrid")!;
for (let row = 0; row < 6; row++) {
grid.fillText(`row ${row}`, 0, row);
}
@@ -1044,9 +1073,7 @@ test("a cell grid narrower than its columns is cut at its content box", async ()
"|
",
);
const canvas = document.getElementById("c") as HTMLCanvasElement;
- const grid = canvas.getContext("termdom-cellgrid" as "2d") as unknown as {
- fillText(text: string, x: number, y: number): void;
- };
+ const grid = canvas.getContext("termdom-cellgrid")!;
grid.fillText("abcdefghijklmnop", 0, 0);
await nextFrame(dom);
expect(rowText(terminal, 0)).toBe("abcde|");
@@ -1107,9 +1134,7 @@ test("a cell grid in a scroller shifts with the terminal's scroll", async () =>
"" +
"";
const canvas = dom.document.getElementById("c") as HTMLCanvasElement;
- const grid = canvas.getContext("termdom-cellgrid" as "2d") as unknown as {
- fillText(text: string, x: number, y: number): void;
- };
+ const grid = canvas.getContext("termdom-cellgrid")!;
for (let row = 0; row < 12; row++) {
grid.fillText(`row ${row}`, 0, row);
}
From 5315782eec63e99889c5b1a04fc856908ba74130 Mon Sep 17 00:00:00 2001
From: Brian Kim
Date: Fri, 9 Oct 2026 11:34:15 -0400
Subject: [PATCH 02/24] Build the image decoder as a worker entry, out of the
package's exports
libuild 0.2.26 builds a src/.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
---
bun.lock | 4 ++--
docs/guides/06-api.md | 3 ---
docs/guides/07-images-and-canvas.md | 5 +++--
package.json | 3 +--
src/{decode-worker.ts => image-decode.worker.ts} | 0
src/index.ts | 6 +++---
src/internal/images.ts | 2 +-
7 files changed, 10 insertions(+), 13 deletions(-)
rename src/{decode-worker.ts => image-decode.worker.ts} (100%)
diff --git a/bun.lock b/bun.lock
index 0fe767cda..09da77fae 100644
--- a/bun.lock
+++ b/bun.lock
@@ -14,7 +14,7 @@
"devDependencies": {
"@b9g/crank": "^0.7.12",
"@b9g/eslint-config": "^0.1.10",
- "@b9g/libuild": "^0.2.25",
+ "@b9g/libuild": "^0.2.26",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.4",
@@ -61,7 +61,7 @@
"@b9g/eslint-config": ["@b9g/eslint-config@0.1.10", "", { "peerDependencies": { "@eslint/js": ">=9", "@stylistic/eslint-plugin": ">=3", "@typescript-eslint/eslint-plugin": ">=8", "@typescript-eslint/parser": ">=8", "eslint": ">=9", "eslint-plugin-acrocase": ">=0.0.1", "eslint-plugin-crank": ">=0.2.2", "eslint-plugin-esfold": ">=0.1.2" } }, "sha512-5uVBZvX+K4XoGilegJ5/tEH4Mzg5gf+/XdOF6I3QJxTX/5iOoyTuMChIKWHPI3oxBAHzbLHYxFeHoNrp7q2gRQ=="],
- "@b9g/libuild": ["@b9g/libuild@0.2.25", "", { "dependencies": { "@b9g/async-context": "^0.2.1", "commander": "^15.0.0", "esbuild": "^0.28.1", "expect": "^30.4.1", "glob": "^13.0.6", "pretty-format": "^30.4.1" }, "peerDependencies": { "playwright": "^1.40.0", "typescript": "^5.0.0 || ^6.0.0" }, "optionalPeers": ["playwright", "typescript"], "bin": { "libuild": "cli.js" } }, "sha512-gASB1QiejGfe5vIGbp2wwIhMvz4T1ppo9RlmtwI8tpVXld8GqXl/7NliU0HiY5IU3ZqaLDTbiick7BXSkKAJgw=="],
+ "@b9g/libuild": ["@b9g/libuild@0.2.26", "", { "dependencies": { "@b9g/async-context": "^0.2.1", "commander": "^15.0.0", "esbuild": "^0.28.1", "expect": "^30.4.1", "glob": "^13.0.6", "pretty-format": "^30.4.1" }, "peerDependencies": { "playwright": "^1.40.0", "typescript": "^5.0.0 || ^6.0.0" }, "optionalPeers": ["playwright", "typescript"], "bin": { "libuild": "cli.js" } }, "sha512-8eZT6nJ+PL5ILlowL64kBntd+PPrH7DpTJHyWazhL6ik9kQwmQpQFJgVtcu27G4FE0BPqtlSYyJMbLPNVE1KlA=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
diff --git a/docs/guides/06-api.md b/docs/guides/06-api.md
index 7dac7b513..082d50001 100644
--- a/docs/guides/06-api.md
+++ b/docs/guides/06-api.md
@@ -373,6 +373,3 @@ is a whole server.
- The types `TermDOMOptions`, `TermDOMEventMap`, `CellSize`,
`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.
diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md
index 70a39d84b..fe0e93300 100644
--- a/docs/guides/07-images-and-canvas.md
+++ b/docs/guides/07-images-and-canvas.md
@@ -209,8 +209,9 @@ one does not stall the page. Bun and Deno have one. Node has one from
thread there. Without a `Worker`, and in a CommonJS build, images decode
on the page's thread in 16 ms slices, so input and frames go on
between slices. A decode that takes more than 10 seconds is stopped
-and that image fails; the images behind it still decode. The worker runs
-the package's `@b9g/termdom/decode-worker` module.
+and that image fails; the images behind it still decode. The worker's
+module ships in the package beside its entry, and TermDOM starts it
+itself.
The worker ends a second after its last decode, and when the last
TermDOM is disposed, so it never keeps a finished program running.
diff --git a/package.json b/package.json
index 5675064dc..38f4d4763 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,6 @@
],
"exports": {
".": "./src/index.ts",
- "./decode-worker": "./src/decode-worker.ts",
"./package.json": "./package.json"
},
"files": [
@@ -59,7 +58,7 @@
"devDependencies": {
"@b9g/crank": "^0.7.12",
"@b9g/eslint-config": "^0.1.10",
- "@b9g/libuild": "^0.2.25",
+ "@b9g/libuild": "^0.2.26",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.4",
diff --git a/src/decode-worker.ts b/src/image-decode.worker.ts
similarity index 100%
rename from src/decode-worker.ts
rename to src/image-decode.worker.ts
diff --git a/src/index.ts b/src/index.ts
index 587c3b607..0be85e0c6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -89,14 +89,14 @@ declare global {
}
}
-// Images decode on the runtime's web Worker, running src/decode-worker.ts,
-// which builds to dist/decode-worker.js beside this file. A runtime with
+// Images decode on the runtime's web Worker, running src/image-decode.worker.ts,
+// which builds to dist/image-decode.worker.js beside this file. A runtime with
// no Worker, as Node is without --experimental-web-worker, and a CommonJS
// build, which has no import.meta.url, decode on this thread instead.
setDecodeWorkerURL(
typeof import.meta.url === "string"
? new URL(
- `./decode-worker.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`,
+ `./image-decode.worker.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`,
import.meta.url,
)
: null,
diff --git a/src/internal/images.ts b/src/internal/images.ts
index 9d644e931..ef0bad52d 100644
--- a/src/internal/images.ts
+++ b/src/internal/images.ts
@@ -2187,7 +2187,7 @@ let current: WorkerState | null = null;
let workersWork = true;
let nextRequest = 0;
-/** The module src/decode-worker.ts builds to, which decodes off-thread. */
+/** The module src/image-decode.worker.ts builds to, which decodes off-thread. */
export function setDecodeWorkerURL(url: URL | null): void {
if (current !== null) {
endWorker(current);
From e6fe843f8354a0b65501de9a3e1ed10363d3eca8 Mon Sep 17 00:00:00 2001
From: Brian Kim
Date: Fri, 9 Oct 2026 11:40:11 -0400
Subject: [PATCH 03/24] Style gauges through css-forms-1's pseudo-elements and
their prefixed 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
---
README.md | 3 +-
docs/guides/04-styling.md | 6 ++-
docs/guides/06-api.md | 22 +++++++--
examples/animated.ts | 3 +-
examples/progress-bar.ts | 3 +-
src/internal/cssom.ts | 63 +++++++++++-------------
src/internal/cssselectors.ts | 43 +++++++++++++++++
src/internal/cssvalues.ts | 9 ++++
src/internal/dom.ts | 22 +++++++--
src/internal/useragent.ts | 14 +++---
tests/widgets.test.ts | 93 ++++++++++++++++++++++++++++++++----
11 files changed, 220 insertions(+), 61 deletions(-)
diff --git a/README.md b/README.md
index 5e69c19ab..f0cb317d9 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,8 @@ document.body.innerHTML = `
diff --git a/docs/guides/04-styling.md b/docs/guides/04-styling.md
index 2c797ea44..c8b424e25 100644
--- a/docs/guides/04-styling.md
+++ b/docs/guides/04-styling.md
@@ -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 `
-
A broken image is a box with its alt text:
+
A broken image shows its alt text:
`;
diff --git a/website/src/models/examples.ts b/website/src/models/examples.ts
index 77a30b7f4..fee791082 100644
--- a/website/src/models/examples.ts
+++ b/website/src/models/examples.ts
@@ -28,6 +28,7 @@ const RUNNABLE = [
"animated",
"bar-chart",
"borders",
+ "canvas",
"chat",
"codemirror",
"commit-editor",
@@ -40,6 +41,7 @@ const RUNNABLE = [
"hacker-news",
"highlights",
"hover",
+ "images",
"lists",
"markdown",
"password",
From df1775e4aac3b0a5ae5aa3f4f0197a64a4544494 Mon Sep 17 00:00:00 2001
From: Brian Kim
Date: Fri, 9 Oct 2026 12:33:54 -0400
Subject: [PATCH 07/24] Say that a cell grid canvas wants its width and height
set
Co-Authored-By: Claude Opus 5.5
---
docs/guides/07-images-and-canvas.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md
index 8c4f0915a..7e161bd08 100644
--- a/docs/guides/07-images-and-canvas.md
+++ b/docs/guides/07-images-and-canvas.md
@@ -285,7 +285,9 @@ Drawing repaints the canvas on the next frame. No DOM change is needed.
### `"termdom-cellgrid"`
The cell grid context draws cells instead of pixels. The canvas is
-`width` columns by `height` rows, and that is its natural size. It is
+`width` columns by `height` rows, and that is its natural size. Set
+both: without them a canvas is HTML's default 300 by 150, which here
+is 300 columns by 150 rows. It is
TermDOM's own, so its name carries TermDOM's prefix, as a browser's own
context types once carried theirs (`"moz-webgl"`, `"webkit-3d"`). Its
interface is not on `window`. The `@b9g/termdom` package exports it as
From 5552bf6130158a9545f083e151c9efe46f5540fc Mon Sep 17 00:00:00 2001
From: Brian Kim
Date: Fri, 9 Oct 2026 13:10:05 -0400
Subject: [PATCH 08/24] Lay a canvas out at width by height CSS pixels whatever
its context
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
---
docs/guides/07-images-and-canvas.md | 32 ++++--
examples/canvas.ts | 5 +-
src/internal/canvas.ts | 14 ++-
src/internal/dom.ts | 73 +++++++++----
src/internal/htmlreflection.ts | 8 +-
src/internal/images.ts | 10 +-
src/internal/replaced.ts | 44 +++++---
tests/canvas.test.ts | 159 +++++++++++++++++++++++-----
8 files changed, 273 insertions(+), 72 deletions(-)
diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md
index 7e161bd08..933d4f5a5 100644
--- a/docs/guides/07-images-and-canvas.md
+++ b/docs/guides/07-images-and-canvas.md
@@ -236,12 +236,28 @@ a cell grid stops at 2^22 cells.
## `