Blitsen

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:

sh
blitsen doctor dist
blitsen doctor dist --json

doctor 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#

AreaIn profile
Application shapeOne built index.html plus the local files reachable from it; root-relative HTML/CSS asset URLs are normalized while ingesting without changing dist
JavaScriptES modules already emitted by the application's bundler. Source is refused, not transpiled — see Built output, not source
Framework DOMStable 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 collectionsquerySelector, 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
EventsCapture/target/bubble listeners, click, mouse, wheel, keyboard, focus, resize and lifecycle events, plus beforeinput/input from typing into a control
Pointer inputpointerdown/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 fullscreenWindows/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-backgetComputedStyle, matchMedia/MediaQueryList, ResizeObserver, CSS.escape/CSS.supports
Geometry and textgetBoundingClientRect, getClientRects, the offset/client/scroll box properties, clientTop/clientLeft, offsetParent, innerText, compareDocumentPosition, elementFromPoint
Ranges and selectionRange 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
Scrollingwindow.scrollTo/scrollBy/scroll, scrollX/scrollY/pageXOffset/pageYOffset, element.scrollTop/scrollLeft, scrollIntoView
ParsinginnerHTML, outerHTML, insertAdjacentHTML, insertAdjacentElement, and DOMParser for text/html into a fragment
SchedulingrequestAnimationFrame, timers and microtasks
Networkingfetch, Headers, Request, Response, Blob, AbortController over http/https, with streaming bodies
AudioWeb Audio — a context, gain, stereo panning and buffer sources over decoded files — and <audio>/new Audio() for whole-file playback
RoutingIn-memory history and location, popstate and hashchange
CSSStatic 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#

sh
blitsen http://localhost:5173      # while `vite` is running in another terminal

The 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
ModulesLoaded 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.urlThe 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 reloadThe 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 yetWaited 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 restartsReads fail while it is down, are named once on stderr, and succeed again when it comes back
build and doctorRefused with a URL. Both read files; a dev server has no output directory to ingest or scan

Two things to know:

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:

sh
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 wroteBlitsen 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 @importSame rewrite, applied transitively.
<a href="/settings">Nothing; anchors are navigation, not subresources.
href="https://cdn…" or //cdn… on a subresourceWarns. 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:

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 runtimePhase 1 (Bun host)
Inline <script type="module">blitsen://app/index.html#script-2file:///…/index.html#script-2
<script type="module" src>blitsen://app/assets/app.jsfile:///…/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#

ContextAudioContext, sampleRate, currentTime, state, destination, resume, suspend, close
NodesGainNode, StereoPannerNode, AudioBufferSourceNode, AudioDestinationNode
ParametersAudioParam — value, setValueAtTime, linearRampToValueAtTime, exponentialRampToValueAtTime, setTargetAtTime, cancelScheduledValues
BuffersdecodeAudioData (promise and callback forms), AudioBuffer, getChannelData
ElementAudio, <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:

ModeClockOutputAnswers
devicereal timethe sound cardwhat an application does
silentreal timenonewhen a sound started and finished
offlineon demandsample bufferswhat 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.

SupportedAbsent
location.href/protocol/host/hostname/port/origin/pathname/search/hashlocation.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, scrollRestorationCross-origin entries — refused with a SecurityError, as in a browser
popstate and hashchange on window, PopStateEvent, HashChangeEvent, document.locationnavigation (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:

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:

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:

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#

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:

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:

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:

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#

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.

js
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#

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#

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 paintMeasured 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-imageCSS 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):

sh
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:

ErrorWhy the page does not come back from it
WEB_FETCHA 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_ENTRYThe 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 v1What a build seesTracked as
Canvas shadows and filterThe 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, ImageBitmapWEB_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 IMEText 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
AccessibilityDeliberately 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, WebRTCWEB_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.

GroupImplementedAbsent
WEB_DOMdocument, 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.forEachElement.attachShadow, Document.currentScript
WEB_FORM_CONTROLSHTMLInputElement, 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.requestSubmitHTMLInputElement.files, HTMLInputElement.labels, HTMLInputElement.validity, HTMLInputElement.checkValidity, HTMLSelectElement.add, HTMLFormElement.submit, HTMLFormElement.reset, HTMLFormElement.action, HTMLFormElement.method, HTMLFormElement.checkValidity
WEB_EVENTSEventTarget, 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_TRANSFERClipboardEvent, DragEvent, DataTransfer, DataTransfer.dropEffect, DataTransfer.effectAllowed, DataTransfer.types, DataTransfer.getData, DataTransfer.setData, DataTransfer.clearData, DataTransfer.pathsDataTransfer.files, DataTransfer.items, DataTransfer.setDragImage
WEB_SCROLLscrollTo, scrollBy, scroll, scrollX, scrollY, pageXOffset, pageYOffset—
WEB_SELECTIONgetSelection, 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.getClientRectRange.deleteContents, Range.extractContents, Range.cloneContents, Range.insertNode, Range.surroundContents
WEB_SCHEDULINGrequestAnimationFrame, cancelAnimationFrame, setTimeout, clearTimeout, setInterval, clearIntervalrequestIdleCallback, cancelIdleCallback
WEB_NETWORKfetch, Headers, Request, Response, Blob, AbortController, AbortSignal—
WEB_URLURL, URLSearchParamsURL.createObjectURL, URL.revokeObjectURL
WEB_ROUTINGwindow, self, location, history, Location, History, PopStateEvent, HashChangeEvent—
WEB_VIEWPORTBlitsenViewElement, BlitsenViewSurface—
WEB_STORAGEStorage, localStorage, sessionStorageindexedDB
WEB_WORKERWorker, Worker.postMessage, Worker.terminateSharedWorker, ServiceWorker, ServiceWorkerContainer
WEB_MESSAGINGMessageChannel, MessagePort, structuredClone, postMessage, MessagePort.postMessage, MessagePort.start, MessagePort.close, BroadcastChannel—
WEB_SOCKETWebSocket, 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_INTLIntl, 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_WASMWebAssembly—
WEB_XHR—XMLHttpRequest
WEB_STREAMReadableStream, WritableStream, TransformStream, Response.body, Response.clone—
WEB_FORMFormData, FileFileReader
WEB_CANVASHTMLCanvasElement, 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.setTransformOffscreenCanvas, 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_MEDIAAudio, 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.closewebkitAudioContext, HTMLMediaElement
WEB_DIALOG—alert, confirm, prompt, print
WEB_NAVIGATIONstopopen, close, navigation, document.write, document.writeln, document.open, document.close, location.assign, location.replace, location.reload, location.ancestorOrigins
WEB_COOKIEHeaders.getSetCookiedocument.cookie, cookieStore
WEB_DEVICENavigator, navigator, navigator.userAgent, navigator.platform, navigator.language, Notificationscreen, caches
WEB_GAMEPADGamepad, GamepadButton, GamepadEvent, GamepadHapticActuator, Navigator.getGamepads, GamepadHapticActuator.playEffect, GamepadHapticActuator.reset—
WEB_OBSERVERResizeObserver, PerformanceObserverIntersectionObserver
WEB_STYLEgetComputedStyle, 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.parentStyleSheetCSSStyleRule, CSSKeyframesRule, CSSKeyframeRule, CSSMediaRule, document.adoptedStyleSheets, CSSStyleSheet.disabled, CSSStyleSheet.replaceSync, CSSStyleSheet.replace, CSSRule.style, CSSRule.selectorText, CSSRule.type
WEB_COMPONENTSDOMParsercustomElements, ShadowRoot
Conditional APIPlatformInstalled when
NotificationdarwinmacOS 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.
DiagnosticSeverityReported as
WEB_FETCHerrorfetch names a path this application does not ship, and there is no server behind it.
WEB_DOMwarningThis DOM method is not implemented.
WEB_FORM_CONTROLSwarningThis form-control API is not implemented.
WEB_TRANSFERwarningThis part of DataTransfer is not implemented; a dropped file is a path, not a File.
WEB_SELECTIONwarningThis part of the range API is not implemented; the boundary, text and geometry reads are.
WEB_SCHEDULINGwarningIdle-callback scheduling is not implemented.
WEB_URLwarningObject URLs are not implemented; URL and URLSearchParams are.
WEB_STORAGEwarningIndexedDB is not implemented.
WEB_WORKERwarningShared and service workers are not implemented; dedicated Worker is.
WEB_XHRwarningXMLHttpRequest is not implemented.
WEB_FORMwarningMultipart form bodies and file objects are not implemented.
WEB_CANVASwarningThis canvas API is not implemented; the 2D context is.
WEB_GPUwarningWebGL, WebGPU and WebRTC are not implemented.
WEB_MEDIAwarningThis media API is not implemented; Web Audio and <audio> are.
WEB_DIALOGwarningModal browser dialogs are not implemented.
WEB_NAVIGATIONwarningDocument navigation is deliberately absent; there is no page to leave.
WEB_COOKIEwarningThere is no origin and no cookie jar behind an exported application.
WEB_DEVICEwarningThis device API is not implemented.
WEB_OBSERVERwarningThis observer is not implemented; only ResizeObserver is.
WEB_STYLEwarningThis part of CSSOM is not implemented; a sheet's rules are its source text.
WEB_COMPONENTSwarningCustom elements and shadow DOM are not implemented; DOMParser is.
CSS_TRANSITIONwarningA property named by transition keeps its pre-stylesheet value (Blitz bug 689).
CSS_FIXEDwarningFixed and sticky boxes resolve against the root box, not the viewport (Blitz bug 690).
CSS_EFFECTwarningThis paint effect is ignored rather than applied.
HTML_SOURCE_ENTRYerrorThis document loads source, not built output; nothing in Blitsen transpiles it.
HTML_MEDIAwarningVideo and text tracks are not implemented; <audio> is.
HTML_SVGwarningThis SVG feature does not paint; shapes, paths, text, gradients and clipPath do.
ASSET_REMOTE_SCRIPTwarningA remote <script src> is not fetched; it is skipped and the rest of the page runs.
ASSET_REMOTEwarningA 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.

js
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:

js
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:

json
{ "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:

ts
/// <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.

ModuleImplementedAbsent
blitsen/appdataDir, cacheDir, configDir, requestSingleInstanceLock, relaunchonQuitRequest, onSuspend, onResume, registerProtocol, registerFileAssociation
blitsen/windowsetSize, setFullscreen, isFullscreen, setDecorations, isDecorated, setMinimized, setMaximized, isMaximized, startDrag, close, setAlwaysOnTop, setCursor, setCursorVisible, setCursorGrab, monitorscreate, setTransparent, isAlwaysOnTop, isMinimized, getCursor, isCursorVisible, getCursorGrab, startFileDrag
blitsen/dialogopenFile, openFiles, saveFile, openFolder, openFolders, message—
blitsen/clipboardreadText, readHtml, readImage, writeText, writeHtml, writeImage, clearreadMime, writeMime
blitsen/trayconfigure, remove, onClick, onAction—
blitsen/menuconfigure, remove, onAction—
blitsen/inputsnapshot, vibrateGamepad, onDeviceChangegamepads
blitsen/hiddevices, open, onDeviceChange—
blitsen/notifyshow, permission, requestPermission, update, close, onEvent—
blitsen/oscpu, memory, storage, host, batteries, localedisplays, idleTime
blitsen/shellopenExternal, openPath, showItemInFolder—
blitsen/processspawn—
Absent memberWhy
app.onQuitRequestA 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.onSuspendLinux has no process-level suspend notification to report. The desktop portals that come closest describe the session, not this application.
app.onResumeThe counterpart of onSuspend, absent for the same reason.
app.registerProtocolRegistering 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.registerFileAssociationThe same .desktop entry, with MimeType instead of a scheme.
window.createThe 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.setTransparentTransparency 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.isAlwaysOnTopwinit 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.isMinimizedwinit 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.getCursorwinit 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.isCursorVisibleThe 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.getCursorGrabThe 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.startFileDragDropping 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.readMimearboard 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.writeMimeThe counterpart of readMime, absent for the same reason.
input.gamepadsThe standard navigator.getGamepads surface already carries every observable controller field. A second native snapshot would only create a competing source of truth.
os.displaysThe 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.idleTimeSeconds 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 modulePlatform where absentWhy
blitsen/menulinuxA 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 memberPlatform where absentWhy

Platform differences#

Where a member exists, it means the same thing everywhere. What differs is underneath it.

MemberWhat differs
app.dataDir, app.cacheDir, app.configDirThe 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.requestSingleInstanceLockBinding 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.relaunchSpawns 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.