Documentation Generated compatibility matrix
v1 compatibility profile#
Blitsen v1 accepts built static applications that stay within the surface below. The profile is deliberately narrower than “works in a browser”: it describes what the current runtime and Blitz renderer can support consistently enough to make an adoption claim.
The tier this profile publishes is PRODUCT.md §7's v1 — the v0
architecture surface plus fetch, WebSocket, images, web fonts, audio playback and the
blitsen/{app,window,dialog,clipboard} modules. window.create is deliberately absent
until the decided isolated-context host model is implemented. What is not v1 is stated as plainly:
WebGL and
WebGPU are absent, accessibility is absent, and text controls provide editing, selection and a
native preedit/commit path, but not the full advanced editing surface or verified input-language
coverage — see What v1 is not.
Window renderer by platform#
Blitsen uses GPU Vello rendering on Windows, Linux and Apple Silicon macOS. Intel macOS uses Vello's CPU rasterizer with a software window backend because Metal compute work can reset WindowServer on affected Intel/Radeon Macs (#229). The renderer and reason are logged when a window opens. CPU rendering can use more CPU and be slower at high pixel densities, during resize and on frequently repainting pages. Android and iOS are deferred; see Bun migration.
Run the check against build output, not source:
blitsen doctor dist
blitsen doctor dist --jsondoctor exits non-zero for profile errors. A build repeats every diagnostic and fails on any
error unless --accept-errors is passed, explicitly accepting the missing behavior, while
warnings let feature-detected fallback paths through. The scan is static and conservative: it finds references, not only
executed paths, which is why severity is narrow. Every rule it applies to
JavaScript comes from the generated manifest below.
Strict v1 surface#
| Area | In profile |
|---|---|
| Application shape | One built index.html plus the local files reachable from it; root-relative HTML/CSS asset URLs are normalized while ingesting without changing dist |
| JavaScript | ES modules already emitted by the application's bundler. Source is refused, not transpiled — see Built output, not source |
| Framework DOM | Stable node identity, standard node type/name/value/owner fields, MutationObserver, creation/insertion/removal, text and attributes, elements, comments, namespaced elements, fragments and <template> |
| Selection and collections | querySelector, querySelectorAll, getElementsByTagName and getElementsByClassName on the document and on an element, getElementById, closest, matches, children and the element-traversal properties, dataset, attributes, static NodeList, classList, link.relList |
| Events | Capture/target/bubble listeners, click, mouse, wheel, keyboard, focus, resize and lifecycle events, plus beforeinput/input from typing into a control |
| Pointer input | pointerdown/pointermove/pointerup/pointercancel with pointerType, pointerId, pressure and isPrimary, for mouse, touch and pen; multi-touch with one pointer per contact; setPointerCapture/releasePointerCapture; the mouse events synthesised behind them — see Pointer events |
| Pointer lock and fullscreen | Windows/macOS pointer lock with relative movementX/movementY (unadjustedMovement is rejected); root-element borderless fullscreen on every desktop; standard state, promises and events — see Pointer lock and fullscreen |
| Style read-back | getComputedStyle, matchMedia/MediaQueryList, ResizeObserver, CSS.escape/CSS.supports |
| Geometry and text | getBoundingClientRect, getClientRects, the offset/client/scroll box properties, clientTop/clientLeft, offsetParent, innerText, compareDocumentPosition, elementFromPoint |
| Ranges and selection | Range and document.createRange for boundary points, text and geometry — getClientRects over a run of characters — caretRangeFromPoint/caretPositionFromPoint, and a Selection a script sets and reads; supported text controls also expose a user-placeable caret and drag selection, but generic document selection remains script-driven and the tree-editing range methods are absent |
| Scrolling | window.scrollTo/scrollBy/scroll, scrollX/scrollY/pageXOffset/pageYOffset, element.scrollTop/scrollLeft, scrollIntoView |
| Parsing | innerHTML, outerHTML, insertAdjacentHTML, insertAdjacentElement, and DOMParser for text/html into a fragment |
| Scheduling | requestAnimationFrame, timers and microtasks |
| Networking | fetch, Headers, Request, Response, Blob, AbortController over http/https, with streaming bodies |
| Audio | Web Audio — a context, gain, stereo panning and buffer sources over decoded files — and <audio>/new Audio() for whole-file playback |
| Routing | In-memory history and location, popstate and hashchange |
| CSS | Static block, flex and grid layout; bounded absolute positioning; spacing, borders, backgrounds, colors and system typography |
| Subresources | <img> and CSS background-image (PNG, JPEG, GIF, WebP and SVG), and @font-face web fonts (WOFF2, WOFF, TTF, OTF), loaded from local files; <video> is not. Audio is loaded and decoded by Web Audio rather than as a renderer subresource — see Audio. A subresource the export cannot serve — a remote URL, or a local file that is missing — is answered with an empty body, so the document paints without it rather than waiting on it |
Font fallback and complex text#
Blitsen enumerates the platform's system fonts and adds every author font loaded through
@font-face to the same shared collection used by DOM and canvas text. CSS family order is kept;
Parley selects a covering face per Unicode cluster and supplies script/locale fallback from the
fonts actually present. HarfRust shapes the selected face, and Unicode bidi determines visual run
order. The automated renderer coverage includes Arabic joining and RTL order, CJK falling through
to a second author family, and a single-scalar outline emoji.
There is no bundled universal fallback font. Applications that require stable coverage or
metrics must ship appropriately licensed fonts and name them in @font-face; otherwise the
available glyphs, advances and line breaks are the host's. If no declared or system face covers a
scalar, the selected face's glyph zero (.notdef) is used. Its appearance is font-defined and can
be a box, another mark or blank—Blitsen does not invent a replacement glyph.
Emoji has the same boundary. An author-provided outline glyph shapes and paints. Platform emoji, colour-font formats, variation-selector presentation and multi-codepoint ZWJ sequences depend on the installed face and the underlying Parley/Vello support and are not claimed by the current tests. Verify those exact sequences on every target where they matter.
P6 byte identity is conditional on font inputs: the conformance corpus and captured framework case use committed author fonts. A page that uses system fonts is expected to differ across operating systems and is deliberately excluded from byte-exact goldens.
The M3b acceptance app intentionally uses the normal Vite default output, including
root-relative /assets/... references and Vite's module-preload bootstrap. It contains no
Blitsen imports or runtime branches.
Development: your own dev server#
blitsen http://localhost:5173 # while `vite` is running in another terminalThe window replaces the browser tab and nothing else about the inner loop
changes. The document, its module graph, its stylesheets and anything it
fetches come from the server; your bundler goes on transforming, watching and
hot-reloading, and source is fine here — a dev server is what compiles it.
What holds:
| Proxy mode | |
|---|---|
| Modules | Loaded over HTTP as served, query strings and all: /src/main.jsx?t=1738 is asked for as written, because that is a different response from /src/main.jsx |
import.meta.url | The application origin, as everywhere else — blitsen://app/src/main.jsx — so an asset resolved against it is a sibling and fetch reads it back through the server |
| Hot reload | The channel is an ordinary WebSocket back to the dev server, and it stays open: messages land on the frame turn like any other socket's |
| A server that is not up yet | Waited for, then reported: blitsen http://localhost:5173 before npm run dev waits ten seconds and then says nothing is answering, and what to do |
| A server that restarts | Reads fail while it is down, are named once on stderr, and succeed again when it comes back |
build and doctor | Refused with a URL. Both read files; a dev server has no output directory to ingest or scan |
Two things to know:
- Vite logs one connection error before its HMR socket connects. Its client derives a socket URL from
location, which is the application origin here and not a host it can reach; it then falls back to the host and port the server injected, which works. Settingserver.hmr.host/clientPortin your Vite config removes the message. - Uncaught stack frames use source maps. Blitsen follows the last standard
//# sourceMappingURL(or block-comment equivalent), including inline data URLs and external maps served with query strings. A missing or malformed map is ignored, leaving the generatedblitsen://app/…frame available. Regular Source Map v3 files and embedded indexed-map sections are decoded; an indexed section that only refers to another unavailable map remains unmapped. Mapping is applied when an uncaught error becomes a runtime diagnostic; readingerror.stackinside application code still returns the engine's own stack.
Built output, not source#
Blitsen loads the module graph a bundler already produced. It transpiles nothing and resolves no bare specifier, by decision — so pointing it at a source tree is refused rather than half-supported:
cd my-app && blitsen # index.html loads /src/main.jsx
blitsen: /src/main.jsx is JSX source, not built output — a browser could not run it either.
Blitsen loads the graph a bundler already produced: build the application (Vite: `vite build`)
and point Blitsen at the output directory..ts, .mts, .cts, .tsx, .jsx, .vue and .svelte at a <script src> are the refusal;
doctor grades the same entrypoint HTML_SOURCE_ENTRY, an error, so a build stops before an
export exists. A bare specifier inside a module — import React from "react" — is refused where it
is resolved, naming the same fix.
This is stricter than it was. The Phase 1 host is Bun, which transpiles JSX and resolves
node_modules itself, so a source tree used to render; an author could develop against something
that would stop working under the shipped runtime, which is exactly the surprise
#90 exists to prevent.
The refusal is about reading source off disk, not about developing. Point Blitsen at your dev
server — blitsen http://localhost:5173 — and the same /src/main.jsx runs, because the server
transforms it and Blitsen reads what it serves. That is a deliberate mode with its own behaviour
(above) rather than an accident of which engine is hosting.
Asset URLs#
There is no web server behind an exported application, so a URL that assumes a server root has to
be resolved against the application instead. Blitsen rewrites server-root URLs while ingesting,
in its own staging copy — your dist directory is never modified.
Running a directory resolves them the same way rather than rewriting anything, so blitsen dist
and blitsen build dist accept the same output. They used to disagree, and the directory the
export accepted was the default vite build one.
A subresource the directory does not carry — a missing file, a remote URL — is named on stderr and the document renders without it, which is what the export already does with it. Only a reference that leaves the application directory is refused, because an export can serve nothing outside what it collected and a directory being run is held to the same files.
| You wrote | Blitsen does |
|---|---|
href="./assets/app.css" | Nothing; relative URLs already work. |
src="/assets/app.js" (default base) | Rewrites to the equivalent document-relative path. |
src="/app/assets/app.js" (custom base: "/app/") | Drops the base prefix that does not exist in the output, then rewrites. |
url("/assets/hero.png") in CSS, and @import | Same rewrite, applied transitively. |
<a href="/settings"> | Nothing; anchors are navigation, not subresources. |
href="https://cdn…" or //cdn… on a subresource | Warns. The request is answered with nothing, so the page renders without that stylesheet, font or image. |
<script src="https://cdn…"> | Warns. The loader skips that one script and says so on stderr; every other script on the page still runs. |
Only HTML and CSS are rewritten. JavaScript is left byte-identical, because a path assembled at runtime cannot be safely edited by a regular expression. In practice:
new URL('./x.png', import.meta.url)and a relativeimport()work — the export preserves your directory layout, so relative resolution lands on the same file it did on a server.fetch('./data.json')andfetch('/data.json')work, and read the file the export ships — see Networking. A literal path that names nothing in the output is diagnosed as an error at build time.new URL('/assets/x.png', import.meta.url)and any specifier built from a variable or template literal are not diagnosed. The server-root form resolves against whatever origin the module is on, so it finds the file inside an export and inside a directory run by the shipped runtime, and lands outside the application on the Phase 1 host, whose modules are onfile:(see Module identifiers). Configure your bundler with a relative base (Vite:base: './') if your application computes asset URLs from a server root, and the question does not arise.
Module identifiers#
A module script is named by an absolute URL, and import.meta.url is that URL. Which origin it is
on depends on the host, and nothing else does:
| Shipped runtime | Phase 1 (Bun host) | |
|---|---|---|
Inline <script type="module"> | blitsen://app/index.html#script-2 | file:///…/index.html#script-2 |
<script type="module" src> | blitsen://app/assets/app.js | file:///…/assets/app.js |
The fragment on an inline module is what makes one distinct from the next; it does not affect
resolution. The Phase 1 host is on file: because its module loader is the filesystem's — that is
also what makes createRequire(import.meta.url) reach a .node addon there — and the shipped
runtime is on the application origin because there is no filesystem inside an executable.
What holds on both, and is what an application depends on: the identifier is an absolute URL, a
relative asset resolved against it is a sibling of the module, and fetch reads that URL out of
the application. test:hosts asserts all three on both hosts, and a directory being run answers
the same way the export does.
Networking#
Bun supplies HTTP(S), Headers, Request, Response, Blob, File, FormData, abort signals,
and streaming bodies. The document's fetch adapter resolves relative application URLs and queues
response/error delivery until the next native frame. Body streams continue on Bun's event loop.
stop() and document disposal abort the document's requests, including active response bodies.
new Request(...) follows Bun and requires an absolute URL; use new URL(path, import.meta.url).
WebSocket uses Bun's transport and buffering. Blitsen retains only document lifetime and event
handoff. Set binaryType explicitly when the application needs blob or arraybuffer; Bun's
default and protocol details follow the pinned Bun version.
Server-sent events#
EventSource is implemented (#236), over the same worker pool fetch and WebSocket run on and
delivered at the same point in the frame turn. The whole of it is there: named events through
addEventListener, MessageEvent with data, lastEventId and origin, readyState and the
three constants, close(), and comment lines that keep an idle connection warm.
Reconnection is the transport's, not the application's. A stream whose body ends — a proxy
timing out, a server restarting — fires error with readyState back at CONNECTING, waits the
interval a retry: field asked for, clamped to at least one second (three seconds if none did),
and reconnects carrying
Last-Event-ID, so a feed resumes where it stopped rather than from the top. A response that is
not a 200 text/event-stream is a different thing: that fires error, settles at CLOSED and is
not retried, because retrying a 404 forever is not a reconnection.
withCredentials is reflected and withholds nothing: there is no cookie store and no per-origin
credential in this runtime, so there is nothing for false to keep back. Only http: and https:
addresses are accepted; anything else is a SyntaxError at construction.
Workers and messaging#
Worker runs Bun on its own thread. Use module workers and file URLs relative to import.meta.url.
The document facade resolves the entrypoint, queues messages for a native frame, and terminates
workers on document disposal. Worker globals and nested workers are Bun's; there is no DOM in a
worker and no Blitsen importScripts implementation.
Bun owns MessageChannel, MessagePort, structuredClone, transfer lists, shared buffers and
worker scheduling. Port events follow Bun's event loop. Clone support, errors and prototypes follow
Bun instead of the former serialized Rust protocol. Transfer data buffers rather than DOM wrappers.
Bun's Worker termination API is experimental; it is not a substitute for process containment.
Use bun:sqlite transactions in workers for blocking database work and node:fs/promises for
filesystem operations. blitsen/process remains the desktop sidecar API; ordinary Bun subprocesses
do not implement the strong containment and crash-recovery contract in issue #441.
Audio#
Backed by web-audio-api, which is a Rust
implementation of the Web Audio API itself rather than a playback library with a graph rebuilt on
top of it — so what an application schedules is what the specification says it scheduled. Decoding
is Symphonia; output is cpal (WASAPI, CoreAudio, ALSA).
Nothing opens the sound card until an application asks it to. The context is created on the
first new AudioContext(), so an application that never plays a sound never touches the device.
A machine with no output device still runs. If the device cannot be opened the context falls back to a silent sink and says so once on stderr. An application then behaves exactly as it would for a user who has muted their speakers, which is the same thing as far as its own code can tell — rather than throwing from a constructor and taking down every page that plays a click.
What is implemented#
| Context | AudioContext, sampleRate, currentTime, state, destination, resume, suspend, close |
| Nodes | GainNode, StereoPannerNode, AudioBufferSourceNode, AudioDestinationNode |
| Parameters | AudioParam — value, setValueAtTime, linearRampToValueAtTime, exponentialRampToValueAtTime, setTargetAtTime, cancelScheduledValues |
| Buffers | decodeAudioData (promise and callback forms), AudioBuffer, getChannelData |
| Element | Audio, <audio>, HTMLAudioElement — play, pause, currentTime, duration, volume, muted, loop, paused, ended, canPlayType |
Formats are whatever Symphonia decodes, which is not selectable: AAC, ADPCM, ALAC, FLAC,
MP1/MP2/MP3, PCM and Vorbis, in AIFF, CAF, ISO/MP4, MKV/WebM, Ogg, WAV and raw containers.
canPlayType answers "probably" or "" and never "maybe", because a maybe tells a caller
nothing.
A media element's source is read the same way, so <audio src="blip.wav"> reads the shipped file
whether the application is a directory being run or an exported executable.
Decoding runs on the worker pool and lands at the same point in the frame turn fetch results
do. A decode in flight keeps the host turning, so its result cannot be stranded.
A source plays once — the specification says so, and starting one twice throws
InvalidStateError. Overlapping playback of one sound is several sources over one decoded buffer,
which is also what makes it cheap: the decode is paid for once.
What is absent, and why#
The rest of the Web Audio graph. BiquadFilterNode, OscillatorNode, AnalyserNode,
ConvolverNode, DelayNode, DynamicsCompressorNode, WaveShaperNode, PannerNode and its HRTF
spatialisation, ChannelSplitterNode/ChannelMergerNode, AudioWorklet, OfflineAudioContext
and AudioListener are all things the backing crate implements and this bridge does not name.
That is deliberate: every API named here is a published claim that doctor and the capability
tiers make on Blitsen's behalf, and the surface above is what an application asking for sound
effects, cues and a background loop actually uses. They are cheap to add when something measured
asks for one.
<audio> is not a streaming media element. The source is fetched whole and decoded whole
before playback starts, which is right for the sounds a desktop application has and wrong for an
hour of audio. buffered, seekable, readyState, networkState, preload, played and
HTMLMediaElement itself are absent rather than answered with a fiction. <video> is not
implemented at all.
webkitAudioContext is absent: it is a prefix for a browser this is not.
A source announces when it finishes: ended fires on the node, and on an <audio> element that
leaves it paused, ended and rewound, so the same element can be played again. The announcement
comes off the render thread and is delivered at the frame turn like everything else, and a sound
that is still playing keeps the host turning — so a loop that never ends is a host that never
idles, which is correct but worth knowing.
Testing audio#
BLITSEN_AUDIO_OFFLINE=1 makes the context an offline one that renders to sample buffers with no
device at all. That is how Blitsen's own harness asserts on audio — reading the samples that came
out, the same way the renderer's tests read painted pixels — rather than on the calls that were
made. A graph built correctly that rendered silence would pass any check that only read properties
back.
An offline context has no clock: it renders when it is asked to and not before, so nothing in it can be observed to finish. Anything about the end of a sound is therefore tested against a real context with a real clock and no output device, which the harness selects for itself. The three modes answer different questions, and only the first is what an application gets:
| Mode | Clock | Output | Answers |
|---|---|---|---|
| device | real time | the sound card | what an application does |
| silent | real time | none | when a sound started and finished |
| offline | on demand | sample buffers | what the samples actually are |
Routing#
history and location exist and are in memory only. There is no navigation, no network and
no back-forward cache — the document is never left, so nothing to navigate to and nothing to
restore. This is the surface a client-side router needs (React Router, Vue Router and
equivalents), and it is deliberately not more than that.
The document address is blitsen://app/. It is synthetic because an exported application has no
server and therefore no origin, and it is path-rooted because that is what a router reads. The
scheme makes the absence of an HTTP origin visible rather than pretending to be localhost.
| Supported | Absent |
|---|---|
location.href/protocol/host/hostname/port/origin/pathname/search/hash | location.assign/replace/reload, ancestorOrigins |
location.hash = … (pushes an entry, fires hashchange) | Assigning href, pathname or search — refused with a NotSupportedError naming pushState, never silently |
history.pushState/replaceState/go/back/forward, length, state, scrollRestoration | Cross-origin entries — refused with a SecurityError, as in a browser |
popstate and hashchange on window, PopStateEvent, HashChangeEvent, document.location | navigation (the Navigation API) |
Two differences from a browser worth knowing. history.state holds the value you pushed rather
than a structured clone of it, so mutating that object mutates the entry. And scrollRestoration
is recorded and reported but restores nothing, because a traversal never reloads a document.
An anchor still does nothing: <a href="/settings"> is navigation, and a router that calls
preventDefault and pushState is what makes it work — which is what every client-side router
already does.
Checked against the real libraries rather than a reading of their source: React Router 7
(createBrowserRouter and createHashRouter, including navigate(-1) traversal through
popstate) and Vue Router 4 (createWebHistory and createWebHashHistory, including
router.back()) resolve, match and traverse routes unmodified on this surface.
Nodes, fragments and templates#
The HTML parser makes node kinds createElement cannot, and framework runtimes need every one of
them: createComment for Vue's v-if and fragment anchors, createElementNS for inline SVG,
createDocumentFragment and <template>.content for Svelte 5's cloned templates. These are real
nodes in the renderer's tree, not JavaScript stand-ins.
Three differences from a browser are worth knowing:
- Collections are static.
children,querySelectorAll,getElementsByTagName,getElementsByClassNameandattributesreturn a snapshot rather than a live collection. A re-query sees a mutation; the collection handed out before it does not. TheAttrobjects inattributesare the exception — each still reads and writes through its element. - A fragment is a detached
<template>element underneath, which is what gives its children a real parent to be parsed, serialized and cloned against — including table rows, which any other parsing context would discard.cloneNode(true)copies by serializing and reparsing, so a clone carries the tree and its attributes and nothing else: no listeners and no JavaScript state, which is what the DOM specifies anyway. template.contenttakes the parsed children off the element the first time it is read, because Blitz has no separate template-contents document. The element is empty afterwards, which is what the specification says it was all along.
A comment's data is fixed when it is created, and data that would close the comment early
(-->) is refused rather than silently truncated. attachShadow remains absent, as does
document.currentScript: nothing in the bridge is told which script element is executing.
setAttributeNS, getAttributeNS and removeAttributeNS key an attribute by namespace and
local name, which is the pair they ask for — so xlink:href round-trips and getAttribute
correctly does not see it. The prefix itself is not stored: getAttributeNames() reports href
and serialization writes href="…", which is already true of markup the parser read.
getClientRects returns one rectangle per line box the element was broken across, off the same
layout flush. Anything with a box of its own has exactly one, and it is the border box
getBoundingClientRect returns; an inline element that wraps has one per line, and their union is
all a single rectangle could have said.
link.relList exists chiefly so that relList.supports("modulepreload") can answer truthfully.
Without it Vite's own module-preload polyfill installs itself and fetches every chunk over an
address with no server behind it, which takes down any code-split build. The preload keywords are
honoured by doing nothing: an exported application's chunks are local files with no cache to warm.
link.onload and link.onerror fire for a <link rel="stylesheet">, including one script
inserted after the document loaded — the path a theme switcher and every deferred-CSS loader takes.
The event is delivered at the frame boundary, where image completions and fetch answers land, and
it is delivered after the sheet is in the cascade: a handler that calls getComputedStyle reads
the values the sheet resolved to, not the ones it replaced. Rewriting href on a link that has
already loaded is a new request and fires again. Three things to know:
- Only
rel="stylesheet"fires either event. Apreload,prefetchoriconlink is never fetched — seerelListabove — so it is owed no outcome, and reporting one would mean announcing a request that was never made. Nothing fires for those, in either direction. - An empty stylesheet file reports
error. The renderer answers a subresource it cannot serve with zero bytes rather than dropping the request, because a dropped one leaves Blitz waiting on a critical resource for the life of the document. That makes "the file is missing" and "the file is genuinely empty" the same signal. An empty sheet contributes nothing to the cascade either way. - Nothing is delivered retroactively. A listener attached after the sheet settled receives nothing, exactly as in a browser. Attach the handler before connecting the element.
Reading style back#
Blitz has already resolved the cascade, evaluated @media and laid the document out. These three
APIs ask it those answers from JavaScript rather than keeping a second idea of what an element's
style is; none of them is a shadow implementation.
getComputedStyle(element) returns a read-only CSSStyleDeclaration over the resolved
style — the stylesheet, not the inline declaration element.style reads. It is live: the same
object reflects a class or attribute mutation on the next read. Custom properties resolve through
inheritance, so a --brand declared on :root reads on any descendant.
Every read is layout-dependent, because CSSOM resolves width and height to the used value:
width: 50% reads as the pixel width layout produced. So a read goes through the same layout flush
getBoundingClientRect does, and a read after a write is a forced synchronous layout — the
expensive kind, counted by BLITSEN_DEV_LAYOUT_WARNINGS alongside the geometry reads. Batch reads
before writes as you would in a browser.
Where it differs from a browser:
- An element the cascade has never reached reads empty. A detached element is not in the document, so this renderer has no resolved style for it at all, and every property reads
""rather than the initial value a browser would invent. Everything connected — includingdisplay: nonesubtrees — resolves normally. - Shorthands serialize from their longhands, and read
""when those longhands do not compose into one — which is also whatalldoes in a browser.margin,padding,border-widthand the rest of the ordinary shorthands compose. - The declaration is not enumerable.
length,item()and index access are absent; ask for the properties you want by name.cssTextis"", which is what CSSOM specifies for a computed block anyway, and every mutator throwsNoModificationAllowedErrorrather than silently ignoring the write. - Pseudo-elements are refused, with a
NotSupportedErrornaming the selector. A pseudo-element box is not addressable here, and answering with the originating element's style would be a wrong answer rather than a missing one. - Only
widthandheightare used values. The inset and box properties report their computed value, which is the declaration resolved to absolute units rather than the used geometry. pointer-eventsreadsautowhere a browser readsall. The cascade parses onlyautoandnone; the nine values that mean "this element takes hits" —all,visible,painted,fill,stroke, thevisible*trio andbounding-box— are rewritten toautoas the CSS enters the document, because the alternative is the cascade dropping them and the element inheriting thenoneof the container it sits in. The element behaves as declared; only the readback is the other word for it. See G15 inBLITZ-GAPS.md.
matchMedia(query) runs the query through the same parser and the same evaluator the cascade
uses for @media, so what matches in a stylesheet matches here. MediaQueryList carries media,
matches, addEventListener("change"), onchange, and the pre-2019 addListener/removeListener
a library still installs; the event is a MediaQueryListEvent with media and matches.
The features the style engine implements are width, height, device-width, device-height,
orientation, aspect-ratio, resolution, device-pixel-ratio, scan, pointer, any-pointer,
hover, any-hover and prefers-color-scheme, and Blitsen adds prefers-reduced-motion on top
(#385). Anything else — prefers-contrast, forced-colors — is an unknown feature to the engine,
and an unknown feature does not match, which is the CSS answer rather than a Blitsen one. An
unparsable query serializes as not all and does not match, as it does in a browser.
prefers-color-scheme and prefers-reduced-motion follow the operating system. On startup
the window reads the system's appearance and motion preferences, and while it runs it follows
changes to them: on Linux through the desktop settings portal (org.freedesktop.appearance
color-scheme, and GNOME's enable-animations for motion), on macOS and Windows through the
window's own theme notification for colour and the accessibility and animation settings for motion.
A change is applied at the start of a frame turn — never part-way through one — where it
invalidates the affected style and dispatches one change event on each live MediaQueryList
whose result flipped; nothing reloads. @media in a stylesheet and matchMedia() read the same
state, so they cannot disagree about the same query. Where a platform has no trustworthy answer,
the fallback is one documented value rather than a guess: light, and no-preference. A dark-mode
toggle driven by a class or localStorage continues to work as it always did, and takes precedence
over the system value in the ordinary CSS way.
prefers-reduced-motion is not a feature Stylo's engine knows, so Blitsen resolves it as a
stylesheet or query arrives and re-resolves it when the preference changes: a <style> element's
text is rewritten in place, a linked sheet that used the feature is fetched again, and the text a
MediaQueryList reports is the query as written. What this cannot reach is a media attribute on
<link> or <style>, which the engine does not evaluate at all.
ResizeObserver observes elements, with observe, unobserve and disconnect. An entry
carries target, contentRect, borderBoxSize and contentBoxSize; contentRect is the content
box positioned from the border box's own origin, exactly as the specification defines it.
Observations are delivered at the start of the frame turn, beside the <blitsen-view> surface
resizes and before any requestAnimationFrame callback — the same defined point in the turn that
network results land at. The first observation for an element is guaranteed: an undelivered
observation keeps the host turning the way an in-flight request does. A browser delivers after
layout and before paint instead, so an entry here describes the layout the previous frame settled
on. box: "device-pixel-content-box" is refused with a TypeError, because the device-pixel
snapping it reports is not exposed; inlineSize is width and blockSize is height, which holds
for every writing mode this renderer lays out.
IntersectionObserver and PerformanceObserver remain absent.
Rendered text, box reads and scrolling#
The read-back surface a component library reaches for once it starts measuring rather than only rendering. Each of these asks Blitz the question rather than keeping a second answer beside it.
innerText is rendered text, which is the whole of what separates it from textContent: a
display: none or visibility: hidden subtree contributes nothing, a <br> is a line break, and
a block-level child starts a new line. What it does not do is re-derive line wrapping — it reads
the tree and its computed display rather than Blitz's line boxes, so a paragraph that wrapped over
three lines is one line here. Writing it is the inverse: newlines become <br> elements.
clientTop and clientLeft are the resolved border widths, read from the computed style
rather than differenced out of the border and content boxes — which would fold the padding in too.
offsetParent is the nearest positioned ancestor, or the body once the walk runs out, and is
null for an element the cascade is not laying out.
compareDocumentPosition returns the DOM's bitmask, computed by walking to the common
ancestor. Two nodes in different trees report DISCONNECTED | PRECEDING |
IMPLEMENTATION_SPECIFIC, as a browser does.
document.elementFromPoint/elementsFromPoint are the hit test the native window already runs
for every click, asked the other way round.
Scrolling. window.scrollTo, scrollBy and scroll move the document — document.scrollingElement,
the root element — and scrollX, scrollY, pageXOffset and pageYOffset read the offset back
live. element.scrollIntoView scrolls each scrolling ancestor and then the document until the
element's border box is inside each one, honouring block and inline including nearest.
behavior is accepted and ignored on both: there is no animation to run, so the scroll lands.
hidden reflects the content attribute, and the user-agent rule mapping [hidden] to
display: none applies as it does in a browser — including being overridden by an author rule that
sets display on the same element, which is ordinary cascade order rather than a Blitsen quirk.
CSS.escape and CSS.supports are present. supports answers from the cascade's own parser, by
round-tripping the declaration through an inline style, so it reports what this runtime accepts;
its one-argument form understands a plain (property: value) condition and answers false for a
compound one rather than guessing at it.
DOMParser parses text/html into a detached fragment, not into a second document — there is
one document in this runtime. body and documentElement are that fragment, and head is null,
because the fragment parser drops <html>, <head> and <body> tags and a parsed string is
therefore not split into head and body. An XML type is refused with a TypeError rather than run
through the HTML parser, which would mis-parse it silently.
What is absent here, and why#
customElementsandShadowRoot— a decision, not an omission. Upgrading elements after parsing, running the lifecycle callbacks and ordering reactions is real machinery, and<blitsen-view>is registered natively rather than through a registry, so a user-defined element would need either its own registry beside that one or a merge of the two. Absent is also the shape a polyfill installs itself into.document.currentScript— the script runner does not carry the script element through to evaluation, and a module script'scurrentScriptisnullin a browser anyway, which is what every bundler in the profile emits.document.doctype— the backend's tree has no doctype node to report.outerWidth,outerHeight,screenX,screenY— the platform layer exposes no window frame or position, andinnerWidth/innerHeightalready answer for the viewport. A second answer that could disagree with those is worse than no answer.visibilityState,execCommand,createTreeWalker,createNodeIterator, and thedocument.forms/images/links/scriptscollections — each needs a reason to exist rather than a reason not to, and none has one yet.
Ranges, carets and the selection#
A range is how text is measured. Every other geometry read in this runtime answers for an
element, and an element is the wrong unit for text: an editor laying out a line needs to know where
characters 4 to 11 of a text node are, and only the range API can ask that. range.getClientRects()
is the answer, and it is a real measurement of the laid-out text rather than an estimate from a
font metric — the same Parley layout the renderer paints from, read at the same flush and charged
as the same forced synchronous layout getBoundingClientRect is.
The list has one rectangle per line box a run was broken across, in line order, plus the border
boxes of the elements the range covers whole. getBoundingClientRect() is their union, and an
empty box when a range measured nothing — text in a display: none subtree, or a collapsed range,
which covers no characters and so returns no rectangles at all.
Offsets are the DOM's, not the layout's. The text Blitz lays out is not the text in the tree:
whitespace has been collapsed across node boundaries, text-transform has rewritten letters, a
<br> has contributed a newline no node owns and a list marker text that no node owns either. A
range counts UTF-16 code units in a text node's own data, the way a JavaScript string does, so
node.textContent.slice(start, end) and the rectangles for start–end always describe the same
characters. Rebuilding that correspondence is what the backend does before it measures.
caretRangeFromPoint(x, y) and caretPositionFromPoint(x, y) are the same reading asked the
other way round: which character is under this point. Both are here because a bundle has one or
the other spelling compiled into it — the first answers with a collapsed range, the second with a
CaretPosition carrying offsetNode, offset and a zero-width getClientRect(). A point over a
box that holds no text has no answer rather than a nearest one, so both return null. The
character the point is inside decides the node: a click on the right-hand half of AB in
AB<span>CD</span> is offset 2 of AB, not offset 0 of CD, even though those name the same
place in the text.
getSelection() returns one object for the life of the document, holding an anchor and a focus
rather than a range — that is what carries direction, which is forward, backward or none
and is what an editor reads to know which end is being dragged. getRangeAt(0) hands back a copy
in tree order rather than the live range a browser gives, and selectionchange is dispatched on
document in a later task, so a run of changes announces itself once, settled.
Two things the selection is not. Nothing paints it: this is the selection a script sets and reads, and the renderer draws no highlight behind it. And the user cannot make one: dragging across text does not move it, because text selection is a shell behaviour and the shell here has none. A widget that maintains its own visible selection — which is what every editor does — works; one that expects the platform to select text for it does not.
Nothing edits through a range. deleteContents, extractContents, cloneContents,
insertNode and surroundContents are absent, and absent rather than half-built: each one splits
a text node at a boundary point, this runtime has no splitText and no character-data interface to
split one with, and a range that cut in the wrong place would be worse than one that does not cut.
Edit the tree with the node methods, and use a range to measure and compare.
Stylesheets#
A stylesheet is the element that owns it. document.styleSheets lists the <style> and
<link rel="stylesheet"> elements the cascade is reading, in the order it applies them;
styleElement.sheet is the same object, one per element for the element's whole life, and
sheet.ownerNode is the element it came from. A disconnected element has no sheet, because
nothing it says has reached the cascade.
That identity is the whole design. A <style> element's text is its sheet's source: Blitz parses
it and hands it to Stylo, and reparses it whenever the text changes. So insertRule and
deleteRule rewrite that text, and the rule they insert is in the same stylesheet set the
document's own rules are in. There is no second rule list that could parse successfully and then
cascade nothing, which is the failure this API is easiest to build.
Two consequences worth knowing:
cssRulesis derived from the source on every read. The list object handed out is a frozen snapshot, like every collection here, but the next read sees what the last mutation did — so an index taken fromcssRules.lengthand passed straight toinsertRule, which is how a framework appends a rule, means what it means in a browser. A rule'scssTextis its source text, not a reserialization, so rewriting a sheet to insert one rule cannot quietly rewrite the others.- A rule is inserted whole or refused. Text that is not exactly one rule — to the cascade's own parser, not just structurally — throws a
SyntaxError, and an out-of-range index throws anIndexSizeError. Nothing is written that the cascade would then ignore. - The element's text follows its rules. A browser keeps
styleElement.textContentat whatever was authored and holds the rule list separately; here they are the same thing, so a sheet mutated throughinsertRulereads back its rules as the element's text. Comments between rules are not rules and do not survive a mutation, which is also what a browser's rule list reports.
What is absent: the CSSRule subclasses and everything read off a rule other than its text
(style, selectorText, type), disabled, replace/replaceSync, constructible sheets
(new CSSStyleSheet() throws, so a feature test selects its fallback) and adoptedStyleSheets.
The rules of a sheet loaded from a URL are refused rather than reported as empty: that sheet's
source is a file this process fetched, not text in the tree. It is still listed in
document.styleSheets and still answers ownerNode and href.
The animation clock#
CSS animations and transitions are sampled at the frame's own timestamp, set from the same
value requestAnimationFrame callbacks receive, once per frame turn. Nothing below that reads a
clock of its own, which is what keeps a replayed or recorded frame sequence identical to the one
that was captured, and a running animation keeps the host turning the way an in-flight request
does.
The consequence is that animation only advances on delivered frames. A harness that loads a
document and never turns the loop sees every animation pinned to its first keyframe — which is
correct, not stalled: no frame has been asked for. This is also why a @keyframes rule inserted
from JavaScript is worth having at all; until the clock was wired to the frame it would have
parsed, cascaded, and never moved.
Form controls#
The whole of this surface rests on one distinction: the content attribute is the control's
default, and the property is its current state. value is not getAttribute("value"). Typing
into a field, or assigning to value, moves the state and leaves the attribute where it was —
HTML calls that the dirty value flag — and from then on the attribute is only the default. So
defaultValue and defaultChecked are the attribute reflections, value and checked are the
state, and each pair moves without the other. Getting this backwards would look like it worked,
which is why it is the thing the tests assert first.
There is one copy of that state and it is the renderer's. Blitz already keeps a text editor for
<input> and <textarea> and a checkedness flag for a checkbox, and those are what it paints
from; value and checked read and write exactly those, rather than a second store beside them
that could disagree with the pixels. Two consequences follow. A value assigned before the control
has ever been laid out is held until Blitz builds its editor and then pushed into it, so nothing is
lost by writing early. And a <textarea>'s child text — its default value, where an input has an
attribute — is given to the editor too, so an untouched textarea paints what it reads and tracks
its children the way HTML says a textarea with no dirty flag does.
<select> and <option> are the exception, because Blitz renders a <select> as its options
rather than as a control and has no notion of selectedness. An option's selectedness is stored as
the same flag a checkbox uses, which is the flag :checked matches against, so select
:checked finds the selected option the way a browser does — Svelte 3 reads a bound select exactly
that way. select.value, selectedIndex, selectedOptions and option.index are all derived
from the options, so there is nothing to keep in step.
options and form.elements are snapshots, like every other collection this runtime hands
out: a re-read sees an option added since, the collection handed out before it does not.
Two divergences worth knowing:
- A drop-down with nothing selected reports its first enabled option rather than
-1. That is the selectedness HTML resets a drop-down to, and it keepsselect.valuemeaningful; what it does not do is stay at-1afterselectedIndex = -1or after assigning a value no option carries. :checkeddoes not restyle. A selector query evaluates it against current state and finds the right element, but changing checkedness does not invalidate the cascade, so a:checkedCSS rule will not repaint. This is Blitz's behaviour for its own checkboxes too.
What a control looks like is a separate question from what it does, and it is the weaker half.
Blitz ships no equivalent of a browser's forms.css, so Blitsen appends the part of that baseline
the engine can honour — control cursors, unselectable labels, a visibly disabled control and an
<a> with no href that is not painted as a link. Blitz's own current sheet supplies the bordered
block and legend rules for <fieldset>.
The controls with no widget behind them are not covered and cannot be by a stylesheet: <select>,
<meter>, <progress> and input[type=range|color|number] paint nothing usable, placeholder
text is not drawn, and only <input> and <textarea> can show a focus ring. See G4 in
BLITZ-GAPS.md. An application that styles its own controls — as most component
libraries do — is unaffected by all of it.
Submission#
form.submit() is absent, and requestSubmit() is not. Submitting a form is defined as
navigating, and navigation is deliberately absent — there is no page to leave. submit() is
defined to skip the submit event and navigate, so an implementation could only be a silent
no-op or a throw; absent lets feature detection see it.
requestSubmit([submitter]) is the half that means something without navigation: it fires a
bubbling, cancelable SubmitEvent at the form, carrying submitter, and does nothing further.
Clicking a submit button does the same, after the click and only if the click was not cancelled.
That is what a single-page application uses — onsubmit plus preventDefault — and it behaves
exactly as it would in a browser. An application that relied on the navigation gets nothing
instead of the wrong page.
A checkbox or radio clicked without the click being cancelled toggles and fires input and
change, and a checked radio clears the rest of its group. form.reset(), action and method
stay absent for the same reason submit() does: they describe a document navigation.
Typing, and the caret#
A key that reaches a focused <input> or <textarea> edits it. The keyboard events are
dispatched first and in full, because the edit is their default action: preventDefault on a
keydown stops the character from being typed, which is how a field that accepts only digits is
written. What happens next is announced and then reported — a cancelable beforeinput naming the
inputType about to be applied, the mutation, then a non-cancelable input saying it was. Both
are InputEvents carrying inputType and data, and input is fired only when the value
actually moved, so backspacing at the start of a field is silent rather than a stream of empty
edits.
The operations behind the keys are insertText, insertLineBreak (Enter, in a <textarea> only —
a single-line field has no line to break, so Enter there is left to the application),
deleteContentBackward/deleteContentForward and their deleteWord pair under Ctrl. Arrow keys,
Home and End move the caret; Shift extends the selection and Ctrl widens each motion to a word or
to the whole value; Ctrl+A selects all. Clicking into a field puts the caret where the click
landed, shift-clicking extends to it, and dragging selects. A key a field took is not also a
scroll — a space typed into one does not page the document down behind it.
Focus moves on mousedown, not on click, because that is the event it is the default action
of and the only one an application can still refuse. A component that focuses something of its own
from a mousedown handler and then cancels the event keeps it — which is how every editor that
paints its own text and funnels keys through one off-screen <textarea> works, including the
Monaco example. Taking focus at click instead handed it back to the nearest
focusable ancestor one event later, so those keystrokes went to the body. Activation — a checkbox
toggling, a submit button submitting — stays on click, where HTML puts it. A press that lands on
nothing focusable still blurs what was focused, as it does in a browser.
selectionStart, selectionEnd, selectionDirection, setSelectionRange() and select() are
implemented on <textarea> and on the single-line-text input types, and are null on the rest:
HTML gives a date or a colour no caret to report, and a component reads that null before it tries
to restore one after a re-render. There is still one copy of the state and it is the renderer's —
the same editor value reads and writes, and the same one the caret and the selection highlight
are painted from — so a range set from script is a range the user can see, and a caret the user
moved is one script reads back. Which node has focus is mirrored into the renderer for the same
reason: nothing paints a caret, a highlight or a :focus rule until it is told.
One divergence, and it is HTML's own bit rather than the editor's: an anchor and a focus can say
forward or backward and have no third answer, so "none" — the direction a range set from script
has until something says otherwise — is kept beside the control and dropped the moment anything
moves the caret.
Native IME preedit and commit have a bounded path. When an editable <input> or <textarea>
holds focus, the window enables winit's IME and continually supplies the viewport-relative Parley
editing area so a desktop candidate window can stay beside the marked text. A preedit lives in
Parley's own composing range, so the renderer paints and shapes it rather than keeping an invisible
bridge-side copy. The first update dispatches compositionstart; every update dispatches
compositionupdate, then cancelable beforeinput, applies insertCompositionText, and reports
input. Commit applies insertFromComposition before compositionend. Those InputEvents carry
isComposing: true; ordinary keyboard edits remain false. Moving focus or receiving IME disable
clears the preedit and ends the composition, and readonly controls never enable it.
That is software-tested with synthetic Unicode preedit/commit sequences and painted author-font fixtures. It is not a claim that a native Chinese, Japanese, Korean, Arabic or other complex input method has been exercised by a user on every target. Winit surrounding-text deletion is not requested or implemented, and the renderer gives marked text no dedicated platform underline. Manual native CJK and RTL input remains release acceptance.
Text-control undo and redo are local and bounded. An accepted key edit, cut or paste is one
transaction; there is deliberately no timing-dependent typing coalescing. All preedit updates plus
their commit are one transaction, while a canceled preedit records nothing. Ctrl/Cmd+Z dispatches
cancelable beforeinput and then input with historyUndo; Ctrl/Cmd+Shift+Z and Ctrl/Cmd+Y use
historyRedo. Both events have data: null and isComposing: false, and restoring a transaction
restores its UTF-16 selection and direction as well as its value. The first undo during a live
preedit cancels only that uncommitted text.
History belongs to one <input> or <textarea> and survives moving focus away and back. A new
edit after undo discards that control's redo branch. A programmatic replacement with a different
.value starts a new controlled state and clears both stacks; a controlled component echoing the
same value from its input listener does not. Each control retains at most 100 transactions and
1,000,000 UTF-16 code units of snapshots. A document reload starts fresh. HTMLFormElement.reset()
remains absent with the rest of full form reset rather than being approximated as a history-only
operation.
What is not here: contenteditable, advanced document selections,
getTargetRanges() on a beforeinput, the selectionchange event, implicit form submission on
Enter, and the change event a text control fires when its value is committed on blur. A framework
that listens for input — React's onChange is input — is unaffected by that last one.
What is absent#
Constraint validation (validity, checkValidity, setCustomValidity), labels and files are
absent rather than stubbed. Each is a surface of its own and each would be a wrong answer if
guessed at: there is no file picker behind an input in this runtime, and no validity model either.
Pointer events#
Input arrives as pointerdown, pointermove, pointerup and pointercancel, carrying
pointerType — "mouse", "touch" or "pen" — a pointerId, isPrimary, and the pressure
the device measured. A touchscreen, a stylus and a precision touchpad are all pointing devices to
the platform underneath, so this is not a mobile feature: it is what a drawing surface reads to
vary a stroke's width, and it works on all six shipping desktop targets.
A MouseEvent is still synthesised behind every pointer event, in that order:
pointerdown then mousedown, pointerup then mouseup then click. That is what browsers do
and it is done here for the same reason — the installed base listens for mouse events. Every
component already running on Blitsen was written against mousedown/click, and it has to keep
working when the press came from a finger. Two rules keep the pair from becoming noise:
- Only the primary pointer synthesises them. A second finger does not fire a second
mousedownat whatever it landed on. - A cancelled
pointerdownsuppresses them for the rest of that contact,clickincluded. This is how an application takes a gesture over: refuse the press, and the compatibility events — along with the focus change and the activation that are their default actions — do not happen.
Every contact is its own pointer. pointerId is stable for the life of one contact and is
never reused: the platform renumbers a finger once it has lifted, and a new contact has to be a new
pointer. Which buttons are held, and which node each of them went down on, is tracked per pointer,
so two fingers pressing two controls are two independent presses and lifting one does not cancel
the other's click.
setPointerCapture/releasePointerCapture/hasPointerCapture are implemented on Element.
Capture is pending until the next pointer event, exactly as the spec says: an element that
captures from its own pointerdown handler is not retroactively that event's target, and
gotpointercapture has not fired by the time the handler returns. From the next event on, every
event from that pointer is retargeted at the capturing element — including the synthesised mouse
events and the click, so a drag that ends outside its handle is still a click on the handle.
Capture is released implicitly when the contact ends, and immediately if the capturing element
leaves the document, which stops a re-render from swallowing the rest of a gesture.
width and height are 1, and tiltX/tiltY/twist/tangentialPressure are 0 unless a tablet
reported them: no platform underneath reports a touch ellipse, and a guessed one would be a
measurement this runtime never made.
What is absent here, and why#
pointerover/pointerout/pointerenter/pointerleave, and their mouse twins. Crossing boundaries is a second piece of state — which element each pointer was last over — and neither the mouse nor the pointer half of it exists today.:hoveris resolved by the renderer and is unaffected.TouchEvent,TouchListandevent.touches. The older, touch-only interface. Pointer events supersede it and a library that wants it can build one from these; shipping both would be two sources of truth for one gesture.touch-action. The CSS property that tells the platform which gestures it may take over. There are no platform-taken gestures to declare (below), so the property would describe nothing.- Scroll and momentum from a touch drag. A touchscreen has no wheel, so dragging a finger scrolls nothing:
wheeland the keyboard are still the only things that scroll. This is a deliberate omission rather than an oversight — panning has to decide when a drag stops being a tap, whether that decision belongs here or in the renderer, and whether momentum is worth a per-frame animator — and none of that is settled. An application that wants a finger to scroll can do it today frompointermoveandelement.scrollTop. Tracked in #145.
Clipboard events and drag and drop#
copy, cut and paste are dispatched at the focused element when Ctrl (or Cmd) is held with
C, X or V, and each carries a clipboardData DataTransfer. Nothing else raises them: there is no
menu bar and no context menu in this runtime, and an application that has its own can dispatch a
ClipboardEvent of its own. Shift and Alt are excluded on purpose, so Ctrl+Shift+C stays the
application's to bind.
The default action is the platform clipboard, through the same arboard backend blitsen/clipboard
uses. A copy nothing cancelled writes the selection — the selected text inside the focused control,
or getSelection() when focus is not in one — and a cut writes it and then deletes it from an
editable control, reporting a beforeinput/input pair with inputType: "deleteByCut". A cancelled
copy or cut writes whatever the listener put in clipboardData instead, which is the whole
reason that store is writable on those two events. paste arrives with the clipboard's text and
HTML already read into a read-only store, and inserts the plain text into the focused editable
control unless the listener cancels it (inputType: "insertFromPaste").
A file dragged in from the desktop is delivered as dragenter, dragover, dragleave and
drop at the element under the pointer, with the same HTML rule browsers apply: the drop is
dispatched only where the preceding dragover was cancelled, because cancelling it is how an
element says it will take the drop. An element that never said so gets the browser default instead,
which in a document with nowhere to navigate to is nothing at all.
zone.addEventListener("dragover", event => event.preventDefault());
zone.addEventListener("drop", event => {
event.preventDefault();
for (const path of event.dataTransfer.paths) open(path);
});dataTransfer.paths is the divergence, and it is the point. A browser answers a drop with
File objects whose bytes must be read back asynchronously, because a page must never learn where
a user keeps their files. An exported Blitsen application is the user's own program, so the honest
answer is the one the platform gave: an absolute filesystem path, opened directly by whatever
filesystem library the application already uses. paths is frozen and in the order the platform
listed them; a path the platform spells in bytes that are not UTF-8 is left out rather than handed
over mangled, because a lossy name opens nothing.
types contains "Files" when the drag carries any, so the check that decides whether to accept a
drop is unchanged, and getData("text/uri-list") returns the same files as file: URLs, built by
the same URL parser location uses. The store is read-only for the duration of a drag, which HTML
spells as setData and clearData doing nothing rather than throwing. DataTransfer.files,
.items and .setDragImage are absent: there is no File in this runtime to put in the first
two, and the third draws for a drag out of the window.
What is absent here, and why#
- Starting a drag —
draggable,dragstart,drag,dragend, and dragging out to the desktop. winit reports a drag that arrives; it has no drag source. Starting one means anIDropSourcewithDoDragDrop, anNSDraggingSessionor awl_data_deviceoffer, and the first two run a modal loop on the thread Blitsen keeps free to paint.blitsen/windowrecordsstartFileDragas absent for the same reason rather than answering it badly. - In-document drag and drop between elements. It needs the same drag source: what winit delivers is a drag the desktop started. A list that reorders itself can do it today from
pointerdown/pointermove, which is what most component libraries already do. dropEffectandeffectAllowedas behaviour. Both are readable and writable and neither changes what the platform does with the drag; there is no source to negotiate a copy-versus-move with.
Canvas#
getContext("2d") returns a real 2D context: paths, fills, strokes, gradients, patterns, images,
text, transforms, clipping and all 27 composite operations. Its contents are composited into the
same frame as the DOM, at the element's own paint position, so z-order, ancestor overflow and
border-radius apply to a canvas exactly as they apply to an image.
What is drawn is recorded as a display list rather than rasterised into a bitmap, and that is the
one structural difference from a browser worth knowing about. Painting a canvas costs no
rasterisation and no upload — the recorded commands are replayed into the frame the renderer was
already drawing — and a canvas scaled by CSS is drawn at the scaled size rather than sampled from
a smaller bitmap. Rasterisation happens only where the specification demands a readback:
getImageData, toDataURL, toBlob, and using one canvas as another's image source.
A canvas that is not in the document draws, reads back and encodes:
document.createElement("canvas"), draw, toDataURL() works without it ever being connected.
That is also what stands in for OffscreenCanvas, which is absent.
Canvas text is shaped from the same font collection the document is laid out with, so a family the
document registered with @font-face is available to ctx.font under its own name. The family
list is passed on as CSS — quoted names, fallback lists and the generic families are all
understood — and the default is the specification's 10px sans-serif. measureText reports the
box that same shaping produced, ink extents included, so a measurement cannot disagree with what
fillText then draws.
Where the canvas surface is narrower than its name#
- Shadows and
filterare absent, and absent means the property does not exist:"shadowBlur" in ctxis false. Both need a blur, and nothing in the paint pipeline under this runtime has one — the same reasondoctorreports CSSfilteras ignored rather than applied. clip("evenodd")clips as if it were non-zero. A clip is a layer in the recorded scene and the renderer's layer takes a path, not a fill rule. Everything else honours the rule it is given:fill("evenodd"),isPointInPath(…, "evenodd")and aPath2Dwith a hole in it are all correct — it is only the clip that cannot express it.ctx.fontresolves relative sizes against 16px, not against the canvas element's own computed font.16px,1.5remand120%all parse; the last two are 24px and 19.2px here rather than whatever the element inherited. Absolute units —px,pt,pc,in,cm,mm,q— and thesmall/largekeywords are exact. Reading the element's computed font would be a forced style resolution on every assignment toctx.font, which is a line inside draw loops.fillStyleandstrokeStyleparse hex,rgb(),rgba(),hsl(),hwb()and the CSS colour keywords. The CSS Color 4 spaces —lab(),oklch(),color()— do not parse, and an unparseable colour is ignored, which is what the canvas specification says to do with one. The previous colour stays in effect; nothing throws.- A destructive composite operation inside a
clip()erases within the clip, not across the canvas.copy,source-in,source-out,destination-inanddestination-atopclear the canvas wherever their source is absent, which is correct, and a canvas that uses one is composited as a group so it erases itself rather than the page behind it. Under an active clip the erasure is scoped to the clip's own layer, where a browser would clear the whole canvas. letterSpacing,wordSpacing,fontKerning,fontStretch,fontVariantCapsandtextRenderingare absent, anddirection: "inherit"resolves toltrrather than reading the element's own direction.toDataURLandtoBlobencode PNG and JPEG. Any other type —image/webpamong them — encodes PNG, which is what the specification says to do with a type an implementation does not support; the data URL's prefix and the blob'stypesay which format came back. Thequalityargument applies to JPEG, and anything outside 0–1 means the encoder's own default.ImageBitmap,createImageBitmap,OffscreenCanvas,captureStreamandtransferControlToOffscreenare absent.drawImageandcreatePatterntake an<img>or another<canvas>, which is what a bitmap source is here.
SVG#
An <svg> subtree paints, and so does an SVG named by <img src> or by a CSS background-image
(issue #238). The element is parsed with usvg and painted through the same Vello scene the rest of
the frame is painted into, so a shape is a filled or stroked path rather than a rasterised image:
it stays sharp at any window scale, and a resize costs nothing but a repaint.
What the focused renderer tests establish:
| Measured to paint | Measured not to paint |
|---|---|
Basic shapes and paths, intrinsic/CSS sizing, viewBox, subtree mutation, fills and strokes including currentColor | <pattern> fills, which additionally mark the frame corner |
SVG files used by <img> and CSS background-image | CSS filter, gated separately by the conformance defect case |
<text>, outlined through the host's fonts — with the caveat below | — |
Blitz/usvg also accepts gradients, dashed strokes, opacity, use/symbol, clipping and other SVG
paint features, but those are not yet individual compatibility oracles. doctor warns on
pattern, filter, mask, foreignObject and SMIL elements (animate, animateTransform,
animateMotion, set); it does not claim to diagnose every unsupported SVG composition.
The element is sized like the replaced element it is: its width/height attributes, or author
CSS, which wins. A viewBox-only <svg> with no width, height or CSS box has nothing to size
itself from and lays out at zero — give it a box.
<text> inside an SVG finds its fonts differently from HTML text, and can find none. The two
go through different font discovery — usvg is given a database built by scanning well-known
directories, HTML text goes through the platform's own — and on a host where they disagree the SVG
text lays out, paints nothing, and takes nothing else with it. It happens on GitHub's Linux runner.
If a chart's axis labels matter, draw them as HTML beside the SVG rather than inside it, or check
them on the machines you ship to; gap G17 in BLITZ-GAPS.md has the detail.
One unsupported case is worse than a no-op and worth knowing about. A <pattern> fill does not
merely fail to paint: the SVG renderer marks unsupported paints with a half-transparent red box
drawn at the frame's top-left corner rather than over the element, so a patterned shape anywhere
in the document leaves a red mark over whatever is in the corner. doctor reports it
(HTML_SVG), and gap G16 in BLITZ-GAPS.md has the detail.
Mutating an SVG subtree from script works — set an attribute on a child and the frame follows,
which is what a charting library does — but the subtree is re-parsed rather than patched, so a
chart that rewrites its paths every frame pays a parse every frame. For per-frame drawing, use
<canvas> or <blitsen-view>.
Intl#
The runtime preserves Bun/JavaScriptCore's full Intl, including parts/range formatting,
Segmenter, DisplayNames, DurationFormat and supportedValuesOf. Locale data and formatting
follow the pinned Bun release. blitsen/os.locale() reads the same default formatter options.
Blitsen no longer ships its ICU4X formatter implementation.
Storage#
localStorage is durable and synchronous. Each value is an atomic keyed file below Blitsen's
platform application-data directory; a small index preserves Storage key order without reading
large values into memory at startup. Interrupted writes retain the old or new complete value, and a
damaged index is quarantined and rebuilt from valid keyed records. There is no Blitsen quota: the
platform filesystem is the limit, and a failed write throws synchronously.
Exports use their packaging identity (--bundle-id, or the stable com.blitsen.<name> default).
Development directories use their canonical path, and proxy runs use the normalized server origin
and entrypoint, so unrelated projects do not share state. Moving a development directory therefore
creates a new namespace; changing a dev-server port does too.
sessionStorage remains private to one JavaScript realm and disappears when that realm is replaced
or the process exits. Blitsen currently opens one window. When multi-window support lands, windows
with the same application identity will share the local area and receive storage events, while
each top-level window keeps its own session area. Workers do not expose Web Storage; a future worker
store must use the same serialized backend rather than a per-thread copy.
indexedDB stays absent.
Device identity#
navigator answers three questions — userAgent, platform, and language/languages — and
nothing else. Those are facts about the machine the application is running on, and they are
answered for the same reason storage is: Svelte 5 reads navigator.userAgent while it hydrates,
without guarding it.
Everything else navigator normally carries is capability rather than identity — clipboard,
geolocation, mediaDevices, serviceWorker, sendBeacon, permissions, onLine,
userAgentData — and all of it stays absent, so a feature test selects a fallback instead of
calling something that cannot work. screen and caches are absent for the same reason. The
standard Notification global is backed by blitsen/notify where the platform can also implement
its lifecycle and close() contract: Linux, Windows and identified macOS application bundles. It
stays absent in an unbundled macOS development host rather than exposing a
constructor with missing lifecycle behavior.
The user-agent string names Blitsen (Blitsen/<version> (Linux x86_64)) instead of impersonating a
browser. An application that sniffs it deserves a true answer more than it deserves a code path
written for someone else's engine.
Unreferenced files#
Ingest walks the output directory from index.html and collects only what it can reach.
Whatever is left over is listed at the end of the build and dropped, because an unreferenced file
is pure export size. Keep some of it with a repeatable glob (* stops at /, ** does not):
blitsen build dist --include 'assets/*.wasm' --include 'locales/**'That is also the escape hatch for a file only a runtime-computed URL reaches.
Where assets live#
--assets embedded (the default) puts every asset inside the executable and unpacks them into a
private temporary directory at launch — one file to ship, nothing to install. --assets
side-loaded writes them to <outfile>.assets/ beside the executable instead, which is the right
choice when assets must stay patchable after shipping or are large enough that carrying them in
the binary is wasteful. Each asset is content-hashed with SHA-256 either way, and repeating a
build from the same input directory, output path and working directory produces a byte-identical
executable.
Diagnostic severity#
Severity answers one question: does the page survive? It is not a measure of how far outside the profile something is. An ignored paint property, a refused web font, an absent API a library feature-detects — the page is still there, slightly plainer or on its fallback path. Those are warnings, reported on every build, and they do not block one.
An error is reserved for the few constructs a page cannot come back from, and the scanner has to be able to see that the construct is unconditional — a guarded one is not one of these:
| Error | Why the page does not come back from it |
|---|---|
WEB_FETCH | A literal server-root URL at a fetch call site is not a capability test, so nothing selects a fallback. The data never arrives, and what renders from it never renders. |
HTML_SOURCE_ENTRY | The document loads .tsx, .jsx, .vue or .svelte — source, which nothing here transpiles and no browser would run either. Blitsen was pointed at a source tree rather than at build output, and the fix is one command: vite build, then point it at dist. |
ASSET_REMOTE_SCRIPT used to be the third, on the reading that the loader refusing one remote
src left the document running no script at all — which is what stopped wordle-plus loading. The
loader now skips that one script, names it on stderr and runs every other script on the page, so
the reason for the severity is gone and grading it an error only blocks a build that works. What
keeps an exported application from phoning home is the runtime refusing to fetch the script, not
the severity of the rule that noticed it.
Everything the scanner finds by naming an absent API is a warning, including WEB_XHR,
WEB_COOKIE, WEB_COMPONENTS, WEB_CANVAS, WEB_NAVIGATION, WEB_WORKER, WEB_GPU,
WEB_DIALOG, WEB_STYLE and WEB_STORAGE. What takes a page down is an unguarded reference to
an absent global; a guarded one selects a fallback and the page carries on. This scan sees
references, not guards, and in real bundles those references are overwhelmingly guarded —
typeof XMLHttpRequest<"u", typeof ShadowRoot<"u", "serviceWorker" in navigator, a
try/catch around document.cookie. Unmodified third-party builds are the evidence:
shadcn-admin carried nineteen such findings and renders its entire admin dashboard, 364 elements
in 16 colours; vue3-realworld carried five and renders. Refusing those builds was the diagnostic
being confidently wrong. --accept-errors now exists for the two remaining error classes, but
these guarded API references are warnings and need no override.
Detecting the guard was the alternative, and it was rejected rather than deferred: the guard is arbitrary minified JavaScript and may be several frames away from the reference, so a detector would work often enough to be trusted and then go quiet on the unguarded reference that does kill a page. Trading a false error for a false silence is a bad trade. The finding is still reported — every one of them, on every build — at the severity a static reference is actually worth. If your application uses one of these APIs on a path that runs, the warning is the notice that it will fail, and the render is what proves it either way.
What v1 is not#
The tier list is the thing the positioning rests on, so the line has to be drawn where the runtime actually draws it rather than where the pitch would prefer.
| Not in v1 | What a build sees | Tracked as |
|---|---|---|
Canvas shadows and filter | The four shadow* properties and ctx.filter are absent, so "shadowBlur" in ctx is false and a feature test selects a fallback. Both need a blur, and the paint pipeline under this renderer has none — the same reason CSS filter is reported ignored | #99 |
OffscreenCanvas, ImageBitmap | WEB_CANVAS, a warning. A canvas that is never in the document is the supported way to draw off-screen: document.createElement("canvas") draws, reads back and encodes without being connected | #99 |
| Advanced text input and IME | Text controls support keyboard and clipboard editing, bounded per-control undo/redo with selection restoration, caret movement, click placement, drag selection, beforeinput/input, and a winit preedit/commit composition path with painted marked text. contenteditable, selectionchange, target ranges, surrounding-text deletion and human-verified native complex-script workflows remain incomplete. Static complex text is shaped separately—see Font fallback and complex text | #103 |
| Accessibility | Deliberately absent: no roles, accessible names, focus state or live regions are exported to the platform, so a screen reader finds nothing. DOM keyboard focus and text editing remain available, but they are not an accessibility tree | #102 |
| WebGL, WebGPU, WebRTC | WEB_GPU, a warning. <blitsen-view> is the supported way to put GPU output on screen | — |
<canvas> used to be the first row of this table and a build-blocking HTML_CANVAS error, on the
reading that an element the renderer paints nothing inside has no degraded appearance to fall back
to. It paints now (issue #99), so the error is gone: an application that draws is no longer the
thing this profile refuses. What the runtime still refuses is a GPU context — getContext("webgl") answers
null, and <blitsen-view> is the supported way to put GPU output on screen.
Capability tiers#
An unimplemented API is absent — the property does not exist — so feature detection works. Blitsen's DOM and rendering surface follows the manifest. Bun supplies its own standard APIs without the previous QuickJS compatibility restrictions.
The tables below are generated from the runtime source. The surface is installed by
crates/blitsen-host/src/dom_bridge.rs, and packages/blitsen/src/api-manifest.mjs reads that
file: which globals it defines, what each class declares, and which globals it deletes. blitsen
doctor reports from the same manifest, and the native harness asserts every absent entry is
genuinely undefined in a real runtime — so the diagnostics, this document and the runtime
cannot drift apart. Regenerate with bun run --cwd packages/blitsen api:sync.
Some surface rows cannot be read as target-independent build facts. The generated native-module table names modules absent from whole platforms, and the conditional tables name individual APIs or members installed everywhere except on the platforms shown. Some web conditions are also decided once per run, where the host determines whether the current process can carry the API. Nothing changes for the application — the API is absent when the condition does not hold, so the same feature detection selects the same fallback — but two runs of one build can answer differently, which is why it is a table rather than a column.
Bun supplies URL, TextEncoder, crypto (including subtle), structuredClone,
performance, queueMicrotask, DOMException and console. The pinned Bun surface is
checked alongside Blitsen's own declarations. Renderer capabilities (CSS_*, HTML_*)
are evidenced by renderer tests rather than JavaScript declarations.
| Group | Implemented | Absent |
|---|---|---|
| WEB_DOM | document, Document, Node, Element, NodeList, DOMTokenList, Attr, NamedNodeMap, CSSStyleDeclaration, MutationObserver, HTMLElement, HTMLElement.click, HTMLIFrameElement, SVGElement, Text, Comment, DocumentFragment, HTMLLinkElement, HTMLTemplateElement, HTMLImageElement, Image, HTMLImageElement.src, HTMLImageElement.naturalWidth, HTMLImageElement.naturalHeight, HTMLImageElement.complete, HTMLImageElement.onload, HTMLImageElement.onerror, Element.querySelector, Element.querySelectorAll, Element.closest, Element.matches, Element.cloneNode, Element.contains, Element.children, Element.previousSibling, Element.lastChild, Element.parentElement, Element.dataset, Element.nodeValue, Element.before, Element.after, Element.getElementsByTagName, Element.outerHTML, Element.insertAdjacentHTML, Element.scrollIntoView, Element.getElementsByClassName, Element.firstElementChild, Element.lastElementChild, Element.nextElementSibling, Element.previousElementSibling, Element.childElementCount, Element.append, Element.prepend, Element.replaceChildren, Element.getAttributeNS, Element.setAttributeNS, Element.removeAttributeNS, Element.hasAttributes, Element.getAttributeNames, Element.toggleAttribute, Element.getClientRects, Element.getRootNode, Element.normalize, Element.attributes, Element.insertAdjacentElement, Element.innerText, Element.compareDocumentPosition, Element.offsetParent, Element.clientTop, Element.clientLeft, Element.hidden, Element.tabIndex, Element.title, Document.title, Document.dir, Document.getElementsByName, Document.elementFromPoint, Document.elementsFromPoint, Document.scrollingElement, Document.characterSet, Document.documentURI, Document.hasFocus, Document.adoptNode, HTMLLinkElement.relList, HTMLLinkElement.onload, HTMLLinkElement.onerror, HTMLTemplateElement.content, DOMTokenList.supports, Document.createElementNS, Document.createComment, Document.createDocumentFragment, Document.getElementsByTagName, Document.getElementsByClassName, Document.importNode, NodeList.item, NodeList.forEach | Element.attachShadow, Document.currentScript |
| WEB_FORM_CONTROLS | HTMLInputElement, HTMLTextAreaElement, HTMLSelectElement, HTMLOptionElement, HTMLButtonElement, HTMLFormElement, HTMLInputElement.value, HTMLInputElement.defaultValue, HTMLInputElement.checked, HTMLInputElement.defaultChecked, HTMLInputElement.type, HTMLInputElement.name, HTMLInputElement.disabled, HTMLInputElement.form, HTMLInputElement.select, HTMLInputElement.setSelectionRange, HTMLInputElement.selectionStart, HTMLInputElement.selectionEnd, HTMLInputElement.selectionDirection, HTMLTextAreaElement.value, HTMLTextAreaElement.defaultValue, HTMLTextAreaElement.select, HTMLTextAreaElement.setSelectionRange, HTMLTextAreaElement.selectionStart, HTMLTextAreaElement.selectionEnd, HTMLTextAreaElement.selectionDirection, HTMLSelectElement.options, HTMLSelectElement.selectedIndex, HTMLSelectElement.value, HTMLSelectElement.length, HTMLSelectElement.selectedOptions, HTMLSelectElement.multiple, HTMLOptionElement.value, HTMLOptionElement.text, HTMLOptionElement.selected, HTMLOptionElement.index, HTMLOptionElement.label, HTMLOptionElement.defaultSelected, HTMLButtonElement.value, HTMLButtonElement.type, HTMLFormElement.elements, HTMLFormElement.requestSubmit | HTMLInputElement.files, HTMLInputElement.labels, HTMLInputElement.validity, HTMLInputElement.checkValidity, HTMLSelectElement.add, HTMLFormElement.submit, HTMLFormElement.reset, HTMLFormElement.action, HTMLFormElement.method, HTMLFormElement.checkValidity |
| WEB_EVENTS | EventTarget, Event, CustomEvent, SubmitEvent, MouseEvent, KeyboardEvent, FocusEvent, InputEvent, CompositionEvent, PointerEvent, WheelEvent, addEventListener, removeEventListener, dispatchEvent, ErrorEvent, Element.setPointerCapture, Element.releasePointerCapture, Element.hasPointerCapture, Element.requestPointerLock, Element.requestFullscreen, Document.pointerLockElement, Document.exitPointerLock, Document.fullscreenElement, Document.fullscreenEnabled, Document.exitFullscreen | — |
| WEB_TRANSFER | ClipboardEvent, DragEvent, DataTransfer, DataTransfer.dropEffect, DataTransfer.effectAllowed, DataTransfer.types, DataTransfer.getData, DataTransfer.setData, DataTransfer.clearData, DataTransfer.paths | DataTransfer.files, DataTransfer.items, DataTransfer.setDragImage |
| WEB_SCROLL | scrollTo, scrollBy, scroll, scrollX, scrollY, pageXOffset, pageYOffset | — |
| WEB_SELECTION | getSelection, Range, Selection, CaretPosition, Document.createRange, Document.getSelection, Document.caretRangeFromPoint, Document.caretPositionFromPoint, Range.setStart, Range.setEnd, Range.setStartBefore, Range.setStartAfter, Range.setEndBefore, Range.setEndAfter, Range.selectNode, Range.selectNodeContents, Range.collapse, Range.cloneRange, Range.startContainer, Range.startOffset, Range.endContainer, Range.endOffset, Range.collapsed, Range.commonAncestorContainer, Range.comparePoint, Range.compareBoundaryPoints, Range.intersectsNode, Range.isPointInRange, Range.toString, Range.getClientRects, Range.getBoundingClientRect, Selection.anchorNode, Selection.anchorOffset, Selection.focusNode, Selection.focusOffset, Selection.isCollapsed, Selection.rangeCount, Selection.type, Selection.direction, Selection.getRangeAt, Selection.addRange, Selection.removeAllRanges, Selection.setBaseAndExtent, Selection.collapse, Selection.extend, Selection.selectAllChildren, Selection.containsNode, Selection.toString, CaretPosition.offsetNode, CaretPosition.offset, CaretPosition.getClientRect | Range.deleteContents, Range.extractContents, Range.cloneContents, Range.insertNode, Range.surroundContents |
| WEB_SCHEDULING | requestAnimationFrame, cancelAnimationFrame, setTimeout, clearTimeout, setInterval, clearInterval | requestIdleCallback, cancelIdleCallback |
| WEB_NETWORK | fetch, Headers, Request, Response, Blob, AbortController, AbortSignal | — |
| WEB_URL | URL, URLSearchParams | URL.createObjectURL, URL.revokeObjectURL |
| WEB_ROUTING | window, self, location, history, Location, History, PopStateEvent, HashChangeEvent | — |
| WEB_VIEWPORT | BlitsenViewElement, BlitsenViewSurface | — |
| WEB_STORAGE | Storage, localStorage, sessionStorage | indexedDB |
| WEB_WORKER | Worker, Worker.postMessage, Worker.terminate | SharedWorker, ServiceWorker, ServiceWorkerContainer |
| WEB_MESSAGING | MessageChannel, MessagePort, structuredClone, postMessage, MessagePort.postMessage, MessagePort.start, MessagePort.close, BroadcastChannel | — |
| WEB_SOCKET | WebSocket, MessageEvent, CloseEvent, EventSource, WebSocket.url, WebSocket.readyState, WebSocket.protocol, WebSocket.extensions, WebSocket.bufferedAmount, WebSocket.binaryType, WebSocket.send, WebSocket.close, EventSource.url, EventSource.readyState, EventSource.withCredentials, EventSource.close | — |
| WEB_INTL | Intl, Intl.NumberFormat, Intl.DateTimeFormat, Intl.RelativeTimeFormat, Intl.PluralRules, Intl.Collator, Intl.ListFormat, Intl.getCanonicalLocales, Intl.NumberFormat.format, Intl.NumberFormat.resolvedOptions, Intl.DateTimeFormat.format, Intl.DateTimeFormat.resolvedOptions, Intl.Collator.compare, Intl.PluralRules.select, Intl.ListFormat.format, Intl.RelativeTimeFormat.format, Intl.NumberFormat.formatToParts, Intl.DateTimeFormat.formatToParts, Intl.DateTimeFormat.formatRange, Intl.Segmenter, Intl.DisplayNames, Intl.DurationFormat, Intl.supportedValuesOf | — |
| WEB_WASM | WebAssembly | — |
| WEB_XHR | — | XMLHttpRequest |
| WEB_STREAM | ReadableStream, WritableStream, TransformStream, Response.body, Response.clone | — |
| WEB_FORM | FormData, File | FileReader |
| WEB_CANVAS | HTMLCanvasElement, CanvasRenderingContext2D, ImageData, Path2D, CanvasGradient, CanvasPattern, TextMetrics, DOMMatrix, HTMLCanvasElement.width, HTMLCanvasElement.height, HTMLCanvasElement.getContext, HTMLCanvasElement.toDataURL, HTMLCanvasElement.toBlob, CanvasRenderingContext2D.canvas, CanvasRenderingContext2D.save, CanvasRenderingContext2D.restore, CanvasRenderingContext2D.reset, CanvasRenderingContext2D.scale, CanvasRenderingContext2D.rotate, CanvasRenderingContext2D.translate, CanvasRenderingContext2D.transform, CanvasRenderingContext2D.setTransform, CanvasRenderingContext2D.resetTransform, CanvasRenderingContext2D.getTransform, CanvasRenderingContext2D.globalAlpha, CanvasRenderingContext2D.globalCompositeOperation, CanvasRenderingContext2D.fillStyle, CanvasRenderingContext2D.strokeStyle, CanvasRenderingContext2D.lineWidth, CanvasRenderingContext2D.lineCap, CanvasRenderingContext2D.lineJoin, CanvasRenderingContext2D.miterLimit, CanvasRenderingContext2D.setLineDash, CanvasRenderingContext2D.getLineDash, CanvasRenderingContext2D.lineDashOffset, CanvasRenderingContext2D.font, CanvasRenderingContext2D.textAlign, CanvasRenderingContext2D.textBaseline, CanvasRenderingContext2D.direction, CanvasRenderingContext2D.imageSmoothingEnabled, CanvasRenderingContext2D.imageSmoothingQuality, CanvasRenderingContext2D.beginPath, CanvasRenderingContext2D.closePath, CanvasRenderingContext2D.moveTo, CanvasRenderingContext2D.lineTo, CanvasRenderingContext2D.quadraticCurveTo, CanvasRenderingContext2D.bezierCurveTo, CanvasRenderingContext2D.arc, CanvasRenderingContext2D.arcTo, CanvasRenderingContext2D.ellipse, CanvasRenderingContext2D.rect, CanvasRenderingContext2D.roundRect, CanvasRenderingContext2D.fill, CanvasRenderingContext2D.stroke, CanvasRenderingContext2D.clip, CanvasRenderingContext2D.isPointInPath, CanvasRenderingContext2D.isPointInStroke, CanvasRenderingContext2D.fillRect, CanvasRenderingContext2D.strokeRect, CanvasRenderingContext2D.clearRect, CanvasRenderingContext2D.fillText, CanvasRenderingContext2D.strokeText, CanvasRenderingContext2D.measureText, CanvasRenderingContext2D.drawImage, CanvasRenderingContext2D.createLinearGradient, CanvasRenderingContext2D.createRadialGradient, CanvasRenderingContext2D.createConicGradient, CanvasRenderingContext2D.createPattern, CanvasRenderingContext2D.createImageData, CanvasRenderingContext2D.getImageData, CanvasRenderingContext2D.putImageData, Path2D.moveTo, Path2D.lineTo, Path2D.bezierCurveTo, Path2D.quadraticCurveTo, Path2D.arc, Path2D.arcTo, Path2D.ellipse, Path2D.rect, Path2D.roundRect, Path2D.closePath, Path2D.addPath, CanvasGradient.addColorStop, CanvasPattern.setTransform | OffscreenCanvas, OffscreenCanvasRenderingContext2D, ImageBitmap, createImageBitmap, HTMLCanvasElement.captureStream, HTMLCanvasElement.transferControlToOffscreen, CanvasRenderingContext2D.shadowBlur, CanvasRenderingContext2D.shadowColor, CanvasRenderingContext2D.shadowOffsetX, CanvasRenderingContext2D.shadowOffsetY, CanvasRenderingContext2D.filter, CanvasRenderingContext2D.letterSpacing, CanvasRenderingContext2D.wordSpacing, CanvasRenderingContext2D.fontKerning, CanvasRenderingContext2D.getContextAttributes, CanvasRenderingContext2D.drawFocusIfNeeded |
| WEB_GPU | — | WebGLRenderingContext, WebGL2RenderingContext, GPUCanvasContext, RTCPeerConnection |
| WEB_MEDIA | Audio, AudioContext, AudioNode, AudioParam, AudioBuffer, AudioBufferSourceNode, AudioDestinationNode, GainNode, StereoPannerNode, HTMLAudioElement, AudioContext.decodeAudioData, AudioContext.createGain, AudioContext.createStereoPanner, AudioContext.createBufferSource, AudioContext.destination, AudioContext.currentTime, AudioContext.sampleRate, AudioContext.resume, AudioContext.suspend, AudioContext.close | webkitAudioContext, HTMLMediaElement |
| WEB_DIALOG | — | alert, confirm, prompt, print |
| WEB_NAVIGATION | stop | open, close, navigation, document.write, document.writeln, document.open, document.close, location.assign, location.replace, location.reload, location.ancestorOrigins |
| WEB_COOKIE | Headers.getSetCookie | document.cookie, cookieStore |
| WEB_DEVICE | Navigator, navigator, navigator.userAgent, navigator.platform, navigator.language, Notification | screen, caches |
| WEB_GAMEPAD | Gamepad, GamepadButton, GamepadEvent, GamepadHapticActuator, Navigator.getGamepads, GamepadHapticActuator.playEffect, GamepadHapticActuator.reset | — |
| WEB_OBSERVER | ResizeObserver, PerformanceObserver | IntersectionObserver |
| WEB_STYLE | getComputedStyle, matchMedia, MediaQueryList, MediaQueryListEvent, CSS, CSSStyleSheet, StyleSheetList, CSSRule, CSSRuleList, HTMLStyleElement, document.styleSheets, HTMLStyleElement.sheet, HTMLLinkElement.sheet, CSSStyleSheet.cssRules, CSSStyleSheet.insertRule, CSSStyleSheet.deleteRule, CSSStyleSheet.ownerNode, CSSStyleSheet.href, CSSStyleSheet.title, CSSRule.cssText, CSSRule.parentStyleSheet | CSSStyleRule, CSSKeyframesRule, CSSKeyframeRule, CSSMediaRule, document.adoptedStyleSheets, CSSStyleSheet.disabled, CSSStyleSheet.replaceSync, CSSStyleSheet.replace, CSSRule.style, CSSRule.selectorText, CSSRule.type |
| WEB_COMPONENTS | DOMParser | customElements, ShadowRoot |
| Conditional API | Platform | Installed when |
|---|---|---|
Notification | darwin | macOS notifications are UNUserNotificationCenter, which needs a bundle identity to address and to hold permission against — and answers a process that has none by aborting it rather than by failing the call, so the facade cannot be installed and left to throw. An exported .app carries that identity and a development run of the interpreter does not; blitsen --dev-bundle gives the development host one of its own rather than borrowing an installed application's. A process cannot acquire or lose a bundle identifier while it runs, so the question is settled once, as the runtime installs (#253). blitsen/notify is present either way and says why a call was refused. |
| Diagnostic | Severity | Reported as |
|---|---|---|
WEB_FETCH | error | fetch names a path this application does not ship, and there is no server behind it. |
WEB_DOM | warning | This DOM method is not implemented. |
WEB_FORM_CONTROLS | warning | This form-control API is not implemented. |
WEB_TRANSFER | warning | This part of DataTransfer is not implemented; a dropped file is a path, not a File. |
WEB_SELECTION | warning | This part of the range API is not implemented; the boundary, text and geometry reads are. |
WEB_SCHEDULING | warning | Idle-callback scheduling is not implemented. |
WEB_URL | warning | Object URLs are not implemented; URL and URLSearchParams are. |
WEB_STORAGE | warning | IndexedDB is not implemented. |
WEB_WORKER | warning | Shared and service workers are not implemented; dedicated Worker is. |
WEB_XHR | warning | XMLHttpRequest is not implemented. |
WEB_FORM | warning | Multipart form bodies and file objects are not implemented. |
WEB_CANVAS | warning | This canvas API is not implemented; the 2D context is. |
WEB_GPU | warning | WebGL, WebGPU and WebRTC are not implemented. |
WEB_MEDIA | warning | This media API is not implemented; Web Audio and <audio> are. |
WEB_DIALOG | warning | Modal browser dialogs are not implemented. |
WEB_NAVIGATION | warning | Document navigation is deliberately absent; there is no page to leave. |
WEB_COOKIE | warning | There is no origin and no cookie jar behind an exported application. |
WEB_DEVICE | warning | This device API is not implemented. |
WEB_OBSERVER | warning | This observer is not implemented; only ResizeObserver is. |
WEB_STYLE | warning | This part of CSSOM is not implemented; a sheet's rules are its source text. |
WEB_COMPONENTS | warning | Custom elements and shadow DOM are not implemented; DOMParser is. |
CSS_TRANSITION | warning | A property named by transition keeps its pre-stylesheet value (Blitz bug 689). |
CSS_FIXED | warning | Fixed and sticky boxes resolve against the root box, not the viewport (Blitz bug 690). |
CSS_EFFECT | warning | This paint effect is ignored rather than applied. |
HTML_SOURCE_ENTRY | error | This document loads source, not built output; nothing in Blitsen transpiles it. |
HTML_MEDIA | warning | Video and text tracks are not implemented; <audio> is. |
HTML_SVG | warning | This SVG feature does not paint; shapes, paths, text, gradients and clipPath do. |
ASSET_REMOTE_SCRIPT | warning | A remote <script src> is not fetched; it is skipped and the rest of the page runs. |
ASSET_REMOTE | warning | A remote asset is not part of a self-contained export; the request is answered with nothing. |
The scanner cannot prove visual equivalence or determine that an unsupported reference is dead code. Treat a zero-error report as the build-time gate and retain visual/interaction acceptance tests for the application itself. See the earlier S6 renderer evidence for why this boundary exists.
Native modules#
The profile above is what a web application may already assume. The blitsen/* modules are the
other direction: focused capability the web has no spelling for, imported under package subpaths
that make the non-portability obvious at the import site.
import app from "blitsen/app";
import clipboard from "blitsen/clipboard";
import windowApi from "blitsen/window";The shipped runtime has no Node or Bun builtins. These modules are not a replacement Node surface:
there is no app.argv, app.quit, generic filesystem, process or raw-socket module. They expose
only the capabilities in the tables below.
Absence is the API. Outside the runtime — a browser tab, a plain Node script — every access on
these modules throws, because importing them there is a mistake. Inside it, a capability this
build does not implement is genuinely undefined, so feature detection works and reads the same
as it does for the web surface:
if (app.requestSingleInstanceLock && !app.requestSingleInstanceLock("My App", relaunchedWith)) {
windowApi.close?.();
}The tables are generated from the same runtime source as the tiers above, by the same reader.
Node and Bun APIs in the shipped runtime#
Development runs, workers, tests and desktop exports use Bun. Bun, process, node:* and
bun:* imports are available with Bun's compatibility limits. Prefer those builtins to new
Blitsen wrappers for files, SQLite, compression, cryptography and subprocesses. Native windows,
rendering, input, menus and desktop integration remain Blitsen's responsibility.
Application files are still collected from built output. Keep Node/Bun builtin imports external
when using Vite/Rollup, or use Bun's bundler with target: "bun". Bundle third-party package code
or explicitly include its runtime files. Document modules currently load synchronously through
Bun's require support; top-level await belongs in an async startup function or a worker.
TypeScript#
The blitsen package carries the definitions, so editor completion works without the runtime
being loadable in a browser context — the types resolve from node_modules, not from a running
application. Extend the published tsconfig fragment:
{ "extends": "blitsen/tsconfig.json", "include": ["src"] }It sets the language level the runtime actually runs, resolves the blitsen/* subpaths through
package exports, and adds blitsen/dom — which is what declares <blitsen-view> and its surface,
including the tag-name map and the JSX namespace, so document.createElement("blitsen-view") types
as itself rather than as HTMLElement. If you would rather not extend it, reference the DOM types
once anywhere in the project:
/// <reference types="blitsen/dom" />Each blitsen/<module> subpath has its own declaration file, so importing blitsen/app offers
the app module's members and not the clipboard's. Every member is optional, because a capability the
running version does not implement is undefined — which means TypeScript will not let you call one
without the feature detection above, and that is the point.
The definitions cannot promise an API that does not exist. They are checked against the
generated manifest in both directions: a declared member the runtime does not install, and an
installed member the definitions do not declare, are each a build failure. bun run --cwd packages/blitsen test:types
typechecks a fixture against the package as it will be published — one file that must compile, one
whose every line must be rejected.
What types do not do is describe the absent half of the web surface. lib.dom.d.ts will still
offer IndexedDB and HTMLCanvasElement, because a package cannot remove a global from an ambient
lib. The capability tiers above are the list, and blitsen doctor is the check.
| Module | Implemented | Absent |
|---|---|---|
blitsen/app | dataDir, cacheDir, configDir, requestSingleInstanceLock, relaunch | onQuitRequest, onSuspend, onResume, registerProtocol, registerFileAssociation |
blitsen/window | setSize, setFullscreen, isFullscreen, setDecorations, isDecorated, setMinimized, setMaximized, isMaximized, startDrag, close, setAlwaysOnTop, setCursor, setCursorVisible, setCursorGrab, monitors | create, setTransparent, isAlwaysOnTop, isMinimized, getCursor, isCursorVisible, getCursorGrab, startFileDrag |
blitsen/dialog | openFile, openFiles, saveFile, openFolder, openFolders, message | — |
blitsen/clipboard | readText, readHtml, readImage, writeText, writeHtml, writeImage, clear | readMime, writeMime |
blitsen/tray | configure, remove, onClick, onAction | — |
blitsen/menu | configure, remove, onAction | — |
blitsen/input | snapshot, vibrateGamepad, onDeviceChange | gamepads |
blitsen/hid | devices, open, onDeviceChange | — |
blitsen/notify | show, permission, requestPermission, update, close, onEvent | — |
blitsen/os | cpu, memory, storage, host, batteries, locale | displays, idleTime |
blitsen/shell | openExternal, openPath, showItemInFolder | — |
blitsen/process | spawn | — |
| Absent member | Why |
|---|---|
app.onQuitRequest | A close request is a window event, and windows are issue #77's to expose; delivering one from here would mean a second, competing event loop. |
app.onSuspend | Linux has no process-level suspend notification to report. The desktop portals that come closest describe the session, not this application. |
app.onResume | The counterpart of onSuspend, absent for the same reason. |
app.registerProtocol | Registering myapp:// on Linux means installing a .desktop entry that names the executable, which is what blitsen build already writes. A running process editing that entry would fight its own packaging. The activation itself arrives: the desktop launches the handler with the URL in argv, and the single-instance lock hands that to the instance already running. |
app.registerFileAssociation | The same .desktop entry, with MimeType instead of a scheme. |
window.create | The architecture is decided: every future window gets an isolated Window, Document, JavaScript heap and evaluated module graph, scheduled with the other windows on one OS UI thread. Application data crosses an explicitly transferred MessagePort; no context receives another window's global. The per-window host state and opaque lifecycle capability needed to uphold that contract are not implemented, so this release exposes no create member. The rest of this module operates on the calling document's native window. |
window.setTransparent | Transparency is chosen when a window is created — winit's own setter does nothing on X11 after that — so honouring it would mean replacing the window, which is create. Run blitsen against a directory whose window should be transparent and the attribute belongs on that window, not on a call. |
window.isAlwaysOnTop | winit sets the window level and cannot read it back, and the window manager may change it without telling the application. Remembering what was last set would be a second source of truth that quietly goes stale. |
window.isMinimized | winit answers is_minimized() as Option<bool> and documents Wayland as always None, so on one of the three desktop platforms there is no answer to give. Reporting false for None would be indistinguishable from a restored window, which is the one thing a caller asks this to tell apart. The alternative is the desired state this module last set, which the window manager and the user change without telling it. |
window.getCursor | winit has no getter for the cursor icon. The value would be the argument the caller last passed, which it already has, and it would be wrong exactly where it is interesting: the platform swaps the cursor itself over a resize edge or during a drag and reports nothing when it does. |
window.isCursorVisible | The counterpart of getCursor: winit sets cursor visibility and cannot read it back. Pointer lock hides the cursor through the DOM path, so a remembered flag here would disagree with the screen for as long as a lock is held. |
window.getCursorGrab | The counterpart of getCursor, and with a second reason: the compositor may refuse or drop a grab — a Wayland client loses one when it loses focus — so the last mode this module set is not the mode in effect. |
window.startFileDrag | Dropping into the window is winit's to report and is implemented; dragging out of it is not something winit can start. A drag source is a platform object driven from the thread that owns the window — IDropSource with DoDragDrop, an NSDraggingSession, a wl_data_device offer — and the first two run a modal loop that does not return until the drop, on the one thread Blitsen keeps free to paint. That is a design question rather than a missing call, so the module says so instead of answering it. |
clipboard.readMime | arboard reads the flavours above and no others. Arbitrary MIME needs a different mechanism on each platform — X11 selection targets, wl_data_offer, NSPasteboardType, a registered Windows format — and no part of that is shared. |
clipboard.writeMime | The counterpart of readMime, absent for the same reason. |
input.gamepads | The standard navigator.getGamepads surface already carries every observable controller field. A second native snapshot would only create a competing source of truth. |
os.displays | The monitors are window.monitors(), which already reports each one's size, position and scale factor. A second list here could disagree with that one. |
os.idleTime | Seconds since the last input is a different mechanism on every platform — the X11 screensaver extension, CGEventSourceSecondsSinceLastEventType, GetLastInputInfo — and Wayland has no answer at all for a client that is not focused: the idle-notify protocol reports crossing a threshold the compositor was asked about, not a duration. Reporting zero on the sessions that cannot answer would be indistinguishable from a machine in use. It is also the one reading in this module that describes the person rather than the machine — how long they have been away from the keyboard, available to any application that asks for it — so implementing it where it happens to work would buy that signal on three platforms in exchange for a wrong answer on the fourth. |
| Native module | Platform where absent | Why |
|---|---|---|
blitsen/menu | linux | A Linux menu bar is a widget inside the window, and the only backend the menu crate has for one is a gtk::MenuBar packed into a gtk::Window — Blitsen windows are winit's, and the renderer owns the whole client area, so there is nowhere to pack it and no GTK main loop to run it. The desktop-level alternative is the D-Bus global menu, which only some desktops implement, needs an X11 window id and so answers nothing on Wayland, and would leave the same application with a menu on KDE and none on GNOME. The tray menu is not this under another name: it belongs to a status item the application may never show. What would change this is a menu bar Blitsen renders itself, which is a different feature — an in-document menu is DOM, not a native one. |
| Conditional native member | Platform where absent | Why |
|---|
Platform differences#
Where a member exists, it means the same thing everywhere. What differs is underneath it.
| Member | What differs |
|---|---|
app.dataDir, app.cacheDir, app.configDir | The application passes its own name, because the runtime does not know one: during development the executable is the host runtime, and a window title is not an identity. The name must be a single path segment. Linux answers with $XDG_DATA_HOME/$XDG_CACHE_HOME/$XDG_CONFIG_HOME and their ~/.local/share, ~/.cache, ~/.config defaults; macOS with ~/Library/Application Support and ~/Library/Caches, so data and config are the same directory; Windows with %APPDATA% and %LOCALAPPDATA%. The directory is returned, never created; the shipped profile has no generic filesystem API with which to create it. |
app.requestSingleInstanceLock | Binding the hand-off endpoint is the ownership claim on every desktop. Unix uses a filesystem socket in a private per-user runtime directory; a socket left after a crash is reclaimed only when connecting proves no listener remains. Windows uses \\.\pipe\blitsen-<user SID>-<application> with a protected DACL. Because a DACL controls access but does not reserve a discoverable name before creation, the client also verifies the connected pipe object's owner SID and the server authenticates the connected client through pipe impersonation. Processes running under the same SID share that trust boundary. The second process's argv and cwd cross as one size- and time-bounded invocation; it remains alive until the owner has authenticated and acknowledged that frame. Delivery then happens on the frame turn, alongside fetch completions, so an application is never re-entered part-way through a frame. |
app.relaunch | Spawns a copy of this process with the same arguments, environment and working directory, and drops the single-instance lock so the successor can take it. It does not stop this process: the application closes its window after flushing what it owns. |
clipboard.* | On X11 and Wayland the process that copied is the one that serves the selection, so what an exported Blitsen application copies disappears when it exits, unless the desktop runs a clipboard manager that takes a copy. macOS and Windows hand the data to the system and it survives. writeHtml also stores the plain text an application that cannot read HTML will paste instead. Images cross as 8-bit RGBA, { width, height, data }, and are carried as PNG on Linux, CF_DIB on Windows and an NSImage on macOS — a decoded image is not guaranteed to be byte-identical to the one that was copied. A read finds null where the clipboard holds nothing in that flavour; a clipboard the session does not offer at all — a headless process — throws instead, because that is an environment refusing rather than an empty clipboard. |
window.* | Operates on the window the run already opened, and is available from the load event onwards — document scripts run before the window exists, and a call before then says so rather than doing nothing. setAlwaysOnTop reaches the X11 window manager and Windows and macOS; Wayland has no protocol for stacking a window above others, so the call is accepted and has no effect there. setCursorGrab("locked") is X11-unsupported and "confined" is macOS-unsupported; both throw naming the platform rather than silently degrading. setSize asks, it does not assert: the size that arrives is whatever the window manager granted, and it is reported by the resize event and innerWidth/innerHeight like any other resize. |
dialog.* | Available on Linux, macOS and Windows. Every dialog is modal to the application window and needs one, so these are available from the load event onwards like window.*. Each returns a Promise, and the frame loop keeps turning while the dialog is up — requestAnimationFrame still fires, the window still paints, and the answer is delivered on a frame turn alongside fetch completions rather than part-way through one. macOS uses asynchronous window-modal panels, Windows performs the blocking COM operation away from the window thread, and Linux uses the XDG desktop portal with a zenity fallback. A dismissed file dialog answers null. Paths are real filesystem paths, not File objects. |
hid.* | Refuses more than any platform does: the Generic Desktop keyboard, keypad, mouse and pointer collections cannot be opened anywhere, and neither can a device carrying one alongside the collection an application wants. On Linux that is the load-bearing case — one hidraw node exposes every collection of the physical device, so the refusal covers the node; Windows and macOS give each top-level collection its own handle, where the same rule refuses only the protected one. Windows additionally reserves some system collections itself, which reports as NotAllowedError rather than as a missing device. macOS opens with shared IOHID access, so Blitsen never seizes a device other software is using, and a sandboxed build needs com.apple.security.device.usb in its signature. Linux needs an installed udev rule; blitsen build writes the template and blitsen doctor says so, and neither ever installs one. Device ids are opaque and last only for the process. Reports are read on a per-device native worker and enter JavaScript only on a frame turn, in order. |
notify.* | IDs live for one application session and stop addressing a notification after its click, action, dismissal, expiry or close event. Linux has no per-application permission state and therefore reports "granted"; development runs use the freedesktop live-process backend, while installed identities use the notification portal and a D-Bus-activatable service so body/actions can start a stopped app. The portal supports replacement and explicit close but exposes neither timeout nor dismissal/expiry callbacks; packaged Linux accepts installed icon-theme names but rejects absolute image paths because those require a sealed descriptor. Modern macOS reports and requests real authorization, and requires the signed .app identity Apple associates it with; it uses that app's icon and rejects a per-notification icon. Windows carries the session ID as the toast's own tag, so update replaces that toast in place and close removes it from the screen and from notification history; permission is the notifier's own setting, so it is "granted" or "denied" and requestPermission() reads rather than prompts. Every completion and lifecycle event enters JavaScript on a frame turn, never from the platform callback thread. A click on a notification whose process has exited arrives as an activation event, delivered once on the first frame turn and never replayed; which platforms can start a stopped application to produce one is PLATFORM-SUPPORT.md. |