Repository navigation
Images after #125: opt-in loading, worker decoding, limits, alt boxes, CellContext grids - #136
Merged
Merged
Conversation
brainkim
force-pushed
the
images-canvas
branch
from
October 4, 2026 17:21
17e4a3b to
d70de9b
Compare
brainkim
force-pushed
the
images-canvas
branch
2 times, most recently
from
October 5, 2026 19:04
7684747 to
ee84a2a
Compare
Images kept their own per-document record of the screen's cell and their own 8 by 16 guess, beside cssom's cell size and fallback and the screen's own guess. The screen now owns the one fallback, cssom keeps the screen's cell beside the page's, and getImageCellSize gives the cell an image's pixels divide by: the page's, or the screen's under the unit cell. 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 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
The charactergrid context kept its cells in a grid type of its own, and images kept their half blocks in the same type, each copied into the frame a cell at a time. Both are CellGrids now. A charactergrid canvas draws through a CellContext over its grid, an image's cells are cached as a grid, and CellContext.drawGrid copies either into the frame. Through CellContext the context gains its lines: strokeStyle, lineStyle (a CSS border style), lineJoin, strokeLine() and strokeRect(). Lines join in corners, tees and crosses where they meet. The canvas's lines are joined among themselves and drawn as glyphs before they reach the frame, so they do not join the page's borders around the canvas. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
An image file names its size before its pixels, so a few bytes could ask for any amount of memory or work. As a browser does, the engine now checks before it allocates: - Every decoder rejects a size past 32768 pixels to a side or 2^26 in all, read from the header before any pixel buffer exists. - A PNG's data inflates only as far as its image needs. The rest is ignored, as browsers ignore it, instead of read whole. - A JPEG has one to four components sampled one to four times, and at most 500 scans, libjpeg-turbo's suggested limit. - A GIF frame's code size is 2 to 8 bits, and its data is gathered as bytes rather than an array of numbers. - data:, file: and fetched images stop at 64 MB, by the declared length when there is one and while reading when there is not. - A new src aborts the request the old one started. - ImageData past 2^26 pixels throws a RangeError, a charactergrid's drawImage past it draws nothing, an image box that would sample past it paints nothing, and a character grid stops at 2^22 cells. Each test but one fails without these changes. The inflate test passes either way: it checks that data past the image is ignored. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
A 12-megapixel JPEG held the page's thread for about 840 ms while it decoded, and the largest image allowed would hold it for seconds. Images now decode on a worker: src/decode-worker.ts, built beside index.js as its own entry, under a web Worker where the runtime has one and Node's worker_threads where it does not. The page's longest stall for that JPEG is 10 to 25 ms on Node, Bun and Deno. A decode that runs past 10 seconds ends the worker, which ends the decode, and the image fails; a new worker takes the next image. An idle worker is ended after a second and never keeps the process alive. Where no worker starts (a CommonJS build has no import.meta.url, and libuild's test bundles have no worker file beside them) decoding stays on the page's thread, and the PNG and JPEG decoders hand the event loop a turn every 16 ms. A worker that started and then failed fails its images instead: what crashed it would crash the page too. The built package was checked on all three runtimes with a stand-in worker whose answers only it could give. The tests use workers from data: URLs for the timeout, an error, and a worker that cannot start. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
An image still loading, broken or blocked showed its alt text inline, or nothing while it loaded. It is now a bordered box with the alt text inside, cut with an ellipsis where it does not fit. The box takes the image's size when the page gives one and otherwise fits the text, three rows tall. One too small for a border, such as a 1 by 1 tracking image, holds the text alone. An empty alt still shows nothing, as decoration, and so does an <img> with neither a src nor an alt. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
TermDOM fetched every image source itself: http(s) through the runtime's fetch, file: from disk and data: with its own parser. A page showing untrusted HTML could not stop a tracking image or a read of a local file, and an email's cid: references had no way to load. Loading is now the program's. TermDOM is an EventTarget, and each load a document's markup asks for is a plain Request, dispatched as a "request" event. The RequestEvent carries what the Request cannot: destination (the Fetch standard's "image"), initiatorType (Resource Timing's "img") and initiator, the element that asked. A listener answers with respondWith(), as a Service Worker answers a fetch event, and the first answer wins; Response.error() or a rejection fails the request. One no listener answers goes to the `fetch` option, and without one it fails, so nothing loads unless the program says so. As in a browser, a document that is not a file: or about: URL loads no file: URL. The page's own window.fetch is the `fetch` given, or the runtime's. A load waits a microtask, as HTML's "update the image data" does, so a listener added just after the constructor's markup hears its images. Those images never loaded before: the parser built them before the document had a window, and only insertion started them. The window now starts them. images.ts no longer reads files or data: URLs; the response a load returns is read under the 64 MB limit. The images example answers file: URLs itself, which Node's fetch does not. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
An image that is not showing is a box now, which sets its alt text off on its own. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
A terminal that draws block characters from the font shows the font's glyph, and Terminal.app does. Menlo's and SF Mono's half blocks sit low in the cell: the upper one covers from 14% to 57% of the cell's height, leaving the cell's top in the background color and running past the middle, so a two-color cell drawn with it was off in two stripes and a gradient banded at every row. With the lower half block the top pixel is the background, which fills whatever the glyph leaves, so only the seam between the two pixels sits a little low. Terminals that draw the blocks themselves (kitty, Ghostty, WezTerm, Alacritty, foot, VTE, Windows Terminal and others) show either exactly. A cell where only the upper pixel shows keeps the upper half block, since the color under it is the page's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
TermDOM became an EventTarget whose "request" events are RequestEvents, but its addEventListener took a plain Event, so every listener cast. TermDOMEventMap names the event for its type, and addEventListener and removeEventListener take keyed overloads first, as lib.dom's do. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
Each round shows four 256 by 256 images and two drawn canvases, paints, removes them and paints again; every other round removes the images before they load. Memory is the heap plus what it holds outside it, as decoded pixels are array buffers the heap does not count. A page that kept its images would hold some 100 MB over the 80 rounds: with every decoded image kept on purpose, the test reads 86 MB and fails. It passes on Node, Bun and Deno. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
Each image was kept decoded at full size, up to 2^26 pixels and 256 MB, though a terminal shows a few hundred thousand pixels at most: a 12 megapixel photo held 48 MB to draw in 40 columns. An image past MAX_KEPT_PIXELS (2^20, 4 MB) is now averaged down to it as it decodes, on the worker or here, and keeps its natural size beside the pixels. That photo keeps 1182 by 886 pixels, 4.2 MB. What a page measures stays the natural size: naturalWidth and naturalHeight, layout and the natural ratio, drawImage's source rectangle, createImageBitmap's crop and size, and the tile a pattern repeats at. sampleBitmap takes its rectangle in natural pixels and maps it to the pixels kept, and the 2d context's drawImage does the same. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
Also stop exporting MAX_IMAGE_SIDE, checkImageSize and ResourceLoader, which only their own files use. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
…le: pages A relative src in a page at about:blank resolved under the process's working directory, which TermDOM read for it. The engine reads only what the terminal sends, so a relative src now resolves against the document's URL or not at all; a program that shows local files sets the url option to one, such as its working directory, as the images example does. The file: rule exempted about: pages, which left a default TermDOM's untrusted markup free to ask for file:///etc/hosts, and Bun's and Deno's fetch read it. Now only a document whose own URL is file: loads file: URLs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
…ponses
The page calls fetch(input, init), but the fetch option takes a Request,
and the runtime's fetch knows nothing of the document's URL: a page's
fetch("/data") reached the option as a string and an init, or failed
as an invalid URL. window.fetch now builds the Request, resolving a
relative URL against the document as a browser does, and hands it to
the fetch given or the runtime's.
An image response that failed on its status or its declared length
left its body unread, which holds the connection open; it is now
cancelled. A waitUntil() promise that rejects no longer goes unhandled.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
A slow image used to end the worker, failing every image it held, and its timer counted time spent waiting in line. Now one image is on the worker at a time and its timer starts when it is sent. A timeout or a crash fails that image alone, and a new worker takes the next. Decoding on the page's thread waits in the same line and stops at its next yield once past the same limit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
… each side A charactergrid drawImage() larger than the pixel limit used to draw nothing. Now it samples just the part of the source that lands on the grid, so any size draws and the work stays bounded by the grid. createImageBitmap() from an image kept smaller than it is used the width's ratio for both sides, and stretched the edge into what a crop took past it. Each side now uses its own ratio, and past the edge is transparent. 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 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
After the slow image times out, a new worker starts for the next one, and its time counts from when the image is sent. On a loaded machine Deno took longer than 200 ms to start it, so the next image failed too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
The event a Content Security Policy fires at an element whose load it blocked (CSP3 §5.1): its documentURI, referrer, blockedURI, effectiveDirective with violatedDirective as its alias, originalPolicy, sourceFile, sample, disposition, statusCode, lineNumber and columnNumber, from an init dictionary whose members all default. A disposition other than "enforce" or "report" is a TypeError. It is on the window like the other event interfaces. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
A TermDOM loaded what its markup asked for only when it was given a fetch, which made the transport the switch: a program that passed one for window.fetch let every image load, and one that passed none saw alt boxes with no reason given. Which loads a document may make is now its Content Security Policy, the contentSecurityPolicy option in a header's syntax, "default-src 'none'" when the program sets none. An <img> loads what img-src allows, or default-src without it. Source expressions match as CSP3 defines them: 'none', 'self', *, schemes, and hosts with wildcards, ports and paths. A page's own <meta http-equiv="Content-Security-Policy"> in its head narrows the policy and never widens it, since a load must pass every policy. A blocked load fires securitypolicyviolation at its element, and when the program set no policy the first one is reported where uncaught errors go, saying what to set. What the policy allows goes to the "request" listeners as before, then to fetch, now a transport only, the runtime's by default. window.fetch is the program's own, so no policy governs it. The file: rule stands: only a file: document loads files. 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 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
A load the policy allows now goes to the TermDOM as a "fetch" event, a FetchEvent as Service Workers define it, in place of the "request" event. It carries the spec's fields: request, with its destination, handled, preloadResponse and the client ids. respondWith() answers and stops the other listeners, and preventDefault() without an answer fails the load. The initiator and initiatorType fields are gone; a program that decides image by image changes the markup before it is inserted. The fetch option is gone. A load no listener answers goes to the runtime's fetch, and window.fetch is the runtime's, so a listener is the one way to answer a load. waitUntil() keeps dispose() waiting, and dispose() aborts loads still in flight. Redirects were followed by the runtime and never checked, so an open redirect on an allowed host could reach any host. Each redirect is now checked against the policy again, matched without its path as CSP3 says, and followed on the network without the listeners, as Fetch follows one without its Service Worker. A violation names the URL the document asked for, with no fragment, and a URL that is not HTTP(S) as its scheme. A path in a source expression is compared piece by piece, each piece percent-decoded without throwing, where a malformed escape such as %E0 threw an uncaught URIError and left the image loading for good. 'self' now requires the same port and allows wss:, and ws: matches the http schemes, as CSP3 says. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
A page's <meta http-equiv="Content-Security-Policy"> was read from the head on every load, so removing it or changing its content loosened the policy. As HTML says, its policy now holds from when the element is inserted as a child of the head, parsed as one policy with report-uri, frame-ancestors and sandbox dropped, and nothing done to the element later changes it. A decode waiting its turn when its image's source changed, or its TermDOM was disposed, used to run anyway and hold its bytes until then. It is now dropped. The image listens for its TermDOM's disposal only while it loads, so a disposed TermDOM aborts the request and the waiting decode and holds nothing of images it has finished with. A decode's time limit restarts when the worker is ready, so starting the worker does not count against the first image. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
A redirect could go to a file, data: or any other URL, since only the policy was checked, and Bun's and Deno's fetch read files: a remote server could send a file: page's image to a local file. As Fetch says, a redirect now goes only to another HTTP(S) URL. A listener that answered with fetch(event.request) let the runtime follow redirects with no check, so a redirect reached any host. As a browser checks a Service Worker's response against the page's policy, a listener's response that came from another URL is checked there, as a redirect target when it was redirected. A load waiting on a listener's answer never ended when its source changed or the TermDOM was disposed, and kept a listener on the TermDOM's disposal meanwhile. The wait now ends when the request is aborted, and handled rejects with a NetworkError. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
brainkim
force-pushed
the
images-canvas
branch
from
October 6, 2026 15:04
080e099 to
354e474
Compare
Without a global Worker, the decoder reached for Node's worker_threads through process, and the worker script found Node's parentPort the same way, though the engine otherwise leaves the process to the transport. It now uses only the runtime's web Worker: Bun's, Deno's, a browser's, and Node's from 26.9 with --experimental-web-worker. Without one, images decode on the page's thread a slice at a time, as a CommonJS build already did. The worker script is plain web worker code. A running worker keeps every runtime alive, and the standard Worker has no unref(), so the worker now also ends when the last TermDOM is disposed, along with any decode it was running. It still ends a second after its last decode. The tests that need a Worker skip without one, which on Node means without the flag. The images guide says how to turn it on. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
TermDOM decodes images on whatever global Worker there is. The images guide now shows the two lines that set one from @b9g/node-webworker on a Node without --experimental-web-worker, leaving Node's own in place where it has one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D
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.
Follow-on work after #125 (images and canvas) merged.
Loading is decided by a Content Security Policy
TermDOM used to fetch every image source itself. It loaded
http(s)through the runtime'sfetch, readfile:from disk and parseddata:itself. A program showing untrusted HTML couldn't stop a tracking image or a read of a local file, and an email'scid:images had no way to load.Which loads a document may make is now its Content Security Policy, the standard built for exactly this question:
<img>loads whatimg-srcallows, ordefault-srcwithout it. Later resource types take their own directives with no new API.'none','self',*(the network schemes and the document's own, notdata:orcid:), schemes, and hosts with*.wildcards, ports and paths. A host without a scheme takes the document's, and for a document not served over the network (about:blank,file:), the network schemes.<meta http-equiv="Content-Security-Policy">inserted into the document's head is enforced too, and a load must pass every policy. As HTML says, it holds from its insertion: removing the tag or changing itscontentlater changes nothing.http:orhttps:URL (never a file ordata:), and at most 20 are followed.securitypolicyviolationfires at the element, a newSecurityPolicyViolationEvent(complete per CSP3) that bubbles to the document with the URL, directive and policy. The URL is the one the document asked for, never a redirect's target, without its fragment, and only its scheme when it isn't HTTP(S). When the program set no policy, the first block is also reported once where uncaught errors go, saying what to set.file:only from file pages. As in a browser, a document whose URL isn'tfile:loads nofile:URL, whatever the policy says.csp: ""sets no policy and allows every load, as an empty header does.What the policy allows goes to the TermDOM as a Service Worker's
FetchEvent, for answers decided request by request, and to the runtime'sfetchwhen no listener answers:cid:takesimg-src cid:and a listener. A redirect, from the network or a listener'sResponse.redirect(), is followed on the network without the listeners, as Fetch follows one without its Service Worker.request,handled,preloadResponse,clientId,resultingClientId,replacesClientId,respondWith(), andwaitUntil()fromExtendableEvent. Both classes are exported. AFetchEventa program makes and dispatches itself can't be answered or extended, as in a Service Worker.beforeloadevent tried this and was removed.fetch(event.request)lets the runtime follow redirects, so its response is checked against the policy at its final URL, as a browser checks a Service Worker's response.dispose()aborts loads still in flight and waits forwaitUntil()promises. A load waiting on a listener's answer ends when itssrcchanges or the TermDOM is disposed, whether or not the answer comes, andhandledrejects with aNetworkError.window.fetchis the runtime's and resolves a relative URL against the document. No policy governs it and it dispatches no event, since in a terminal the program, not untrusted markup, calls it.htmlnever loaded before. The parser built them before the document had a window, and nothing restarted them. The window now does.examples/images.tssetsimg-src file: data: https:and answersfile:URLs in afetchlistener, since Node'sfetchdoesn't read files. The guide's "Loading" section covers the policy and the event.Decoding on the runtime's Worker, or on the page's thread
A 12-megapixel JPEG blocked the page for about 840 ms. Images now decode in
src/decode-worker.ts, plain web worker code built next toindex.jsas its own entry (listed inexports, since libuild only builds entriesexportsnames). It runs on the runtime's own globalWorker: Bun's, Deno's, a browser's, and Node's from 26.9 with--experimental-web-worker(nodejs/node#64894). There, the longest block for that JPEG is 10 to 22 ms. TermDOM does not touchprocessorworker_threadsfor this, and does not offerwindow.Workerto pages.@b9g/node-webworker0.2.2, which the images guide shows:globalThis.Worker ??= Worker. TermDOM does not depend on it.srcchanges, or its TermDOM is disposed, leaves the line with its bytes.unref(). So the worker ends a second after its last decode, and when the last TermDOM is disposed, along with any decode it was running.At most a megapixel kept per image
An image used to stay decoded at full size, up to 2^26 pixels (256 MB), though a terminal shows a few hundred thousand pixels at most. A 12-megapixel photo held 48 MB to draw in 40 columns. An image past
MAX_KEPT_PIXELS(2^20 pixels, 4 MB) is now averaged down to it as it decodes, on the worker or the main thread, and keeps its natural size beside the pixels: that photo keeps 1182×886 pixels, 4.2 MB.Everything a page measures still uses the natural size:
naturalWidth/naturalHeight, layout and aspect ratio,drawImage's source rectangle,createImageBitmap's crop and size (each side scaled by its own ratio, transparent past the image's edge), and the tile a pattern repeats at.sampleBitmaptakes its rectangle in natural pixels and maps it to the pixels kept.Limits against image bombs
Checked before anything is allocated:
srcaborts the request the old one startedImageDatapast 2^26 pixels throws aRangeErrordrawImagesamples only the cells that land on the grid, however large the destinationAn image that isn't showing is a box with its alt text
An image that's loading, broken or blocked showed its alt text inline, or nothing at all while loading. It's now a bordered box with the alt text inside, cut with "…" to fit. The box takes the image's size when the page gives one, and otherwise fits the text, three rows tall. One too small for a border holds the text alone.
alt=""shows nothing, and neither does an<img>with nosrcand noalt. The border is a placeholder, not a CSS border: it's drawn inside the content box in the text color.charactergrid through CellContext, with lines and boxes
The charactergrid context and image half blocks each kept cells in their own type and copied them into the frame one cell at a time. Both are
CellGrids now, andCellContext.drawGridcopies either into the frame. The context gains canvas-style lines:Lines join into corners, tees and crosses where they meet. They're drawn as glyphs before reaching the frame, so they don't join the page's borders around the canvas.
Two pixels as a lower half block
A two-color cell is now
▄(bottom pixel) over a background (top pixel), not▀over a background. Most terminals draw block characters themselves and show either exactly (kitty, Ghostty, WezTerm, Alacritty, foot, VTE, Windows Terminal, xterm.js with WebGL). Terminal.app draws them from the font, and of 22 coding fonts measured, 12 put the split between the halves off center, mostly low (Menlo and SF Mono at 57–59% of the cell, and their blocks also start 14–16% below the top). With▀, that left a stripe of the wrong color at each cell's top and banded every gradient. With▄the background fills whatever the glyph misses, so only the seam is off, and▄matches or beats▀in 18 of the 19 fonts that have half blocks. timg, viu and rich-pixels use▄for the same reason. A cell where only the top pixel shows keeps▀, since the color under it is the page's.Smaller changes
Typed listeners:
TermDOMEventMapand keyedaddEventListener/removeEventListeneroverloads, so a"fetch"listener gets aFetchEventwithout a cast.A leak test for images and canvases: 80 rounds of four 256×256 images and two drawn canvases, shown and removed, with half the images removed before they load. It measures the heap plus external memory, since decoded pixels are array buffers the heap doesn't count. With every decoded image deliberately kept, it reads 86 MB and fails; it passes on Node, Bun and Deno.
One cell size for images: the screen owns the one 8×16 fallback, and
getImageCellSizegives the cell an image's pixels divide by.Guide: now says how image sizes work with and without
cellSize.Not done
renderANSI(html)andprint(html)never show images: they render before an image decodes.🤖 Generated with Claude Code
https://claude.ai/code/session_015Bw3yEu4cfkuDkaZ3RSy4D