API reference
Every export, option and contract of Underglass. React applications import from
@underglass/react and Vue 3 applications from @underglass/vue; both re-export the
framework-neutral helpers and types of @underglass/core, which plain-DOM applications
and new adapters use directly. Only the package entries named on this page are public:
do not import internal modules or renderer classes. Underglass is experimental (0.x) and
not production-ready; each package’s CHANGELOG.md records what changed, and
distribution covers installing, peers and browser requirements.
The guide walks through the same API step by step, and the
effects contract covers setEffects, effect handles and trusted
defineEffect authoring in detail. For complete applications built on these exports, see
the playground’s task app (examples/playground/src/apps/todo/), whose numeric
layout and appearance compile to browser CSS and whose task events ripple a separate
decorative surface, and /effects.html, which composes effects with motion while keeping
essential native controls separate. Open issues lists the known limits.
Build-time CSS and worker exports (experimental)
Section titled “Build-time CSS and worker exports (experimental)”These are separate opt-in routes; neither automatically adapts arbitrary components or integrates the compiler with the worker backend.
| Public entry or API | Contract |
|---|---|
@underglass/css-compiler/vite and /webpack |
Production-build adapters configured by underglass.config.json; selected local/package styles use the application’s verified preprocessing pipeline. |
virtual:underglass/compiled |
Generated CompiledProgramSet: literal WGSL/bindings and matching numeric native-geometry samplers. Pass it as compiled to mountCanvasApp. |
controller.ownCompiledAnimation(id, animationId) |
Explicit ownership of an enrolled original element’s matching CSS animation. Returns the existing playback interface; disable/release/disposal restores native behavior. |
@underglass/react/build |
Build-only compositor/animation generators, including workerCompositorProgram(1..8). Do not import this entry into the compiled browser runtime. |
@underglass/react/experimental-worker |
mountWorkerCanvasApp, WorkerSurface and public worker types. Essential/native-control geometry is fixed; only inert decoration animates. |
@underglass/react/experimental-worker/thread |
startWorkerCompositor(self) for an application-owned module worker. React, DOM/layout and native capture remain main-thread. |
@underglass/core |
The framework-neutral runtime for framework adapters and plain-DOM applications: CanvasRuntime, compileStyle (typed Layout/Appearance to a CompiledStyle of complete string values), affine/pivot, plus every helper and type @underglass/react re-exports. Imports no UI framework. |
@underglass/vue |
The Vue 3 adapter (vue@^3.5 peer): mountCanvasApp(options, RootComponent, rootProps?), Surface, Box, NativeScroll, NativeOverlay, Presence (emits complete), useSurfaceRef, useGPUHost, useNativeOverlayHost, plus the same core helpers and types @underglass/react re-exports. See the frameworks guide. |
@underglass/core/build, /experimental-worker, /experimental-worker/thread |
Framework-neutral originals of the React package’s /build and /experimental-worker/thread entries, and the worker protocol and capture primitives the React worker controller builds on. |
Compiled and worker runtime routes consume prebuilt shaders; the default
noncompiled route still generates WGSL internally. Device-specific pipeline
compilation, live uniforms, native geometry and lifecycle remain runtime work.
The worker controller exposes enable, disable, dispose, invalidate,
settle, state and asynchronous statistics; teardown rejection requires
handling, not forced DOM restoration before worker acknowledgement.
See the compiler configuration/enrollment/subset guide, worker setup/lifecycle guide and measurement limits. Chrome Canary with HTML-in-Canvas enabled is required for GPU operation; ordinary DOM remains the initial/fallback presentation.
GPU presentation policy
Section titled “GPU presentation policy”The policy revokes GPU for CSS/WAAPI animations only when they run on an
enrolled boundary element and animate GPU-owned presentation properties:
transform, opacity, translate, rotate, scale, transform-origin, or
transition-property: all. Descendant animations and transitions (hover
effects, spinners, focus rings) stay in GPU mode: they repaint native content,
which the paint event captures. Verified in Canary 157.0.8102.0, where a
spinning descendant kept GPU active with about 62 fresh uploads per second.
Structural policy checks (style/DOM-derived) are cached and re-evaluated after
mutations, resize, scroll, animation/transition events, input/focus, membership,
ownership, effects and phase changes. Ownership checks and transform-dependent
geometry still run every frame. Paint events upload only layers listed in
changedElements, plus stale ones (new slot, extent/grid change, invalidated,
or after a hidden-document pause); unchanged layers are not re-uploaded.
Explicit bootstrap
Section titled “Explicit bootstrap”The runtime follows the GPU-first contract and capability matrix. After enabling GPU, normal structure/layout/DPR changes, visibility pauses and reduced motion do not revoke GPU. Only GPU errors or factual missing required GPU/capture/geometry support justify diagnosed fallback; explicit disable and disposal are caller lifecycle. Membership and geometry changes are reconciled in the same renderer and output owner.
Create an empty output canvas and ordinary/overlay hosts as connected,
same-document siblings outside all React roots. Put them inside a sized,
positioned frame. The output canvas must fill that frame at the same
origin (captureHost is optional and deprecated: native capture happens on
outputCanvas, and the legacy element was only removed on dispose); the GPU output owns original native hit testing when enabled. Supply the ordinary host at the
same layout origin. createCanvasHosts(frame) creates all three with the required
styles (Getting started); the playground’s
runtime.html shows the same scaffolding by hand. Those styles are host integration,
not layer authoring.
import { useState } from "react";import { mountCanvasApp, Surface, Box, NativeOverlay } from "@underglass/react";import type { CanvasStatus } from "@underglass/react";
function App() { const [name, setName] = useState(""); const [saved, setSaved] = useState(""); return <> <Surface id="editor" layout={{ x: 20, y: 20, width: 260, height: 200, padding: 12 }} appearance={{ background: "white", color: "black" }}> <Box layout={{ direction: "column", gap: 8 }}> <strong>Native controls</strong> <form onSubmit={event => { event.preventDefault(); setSaved(name); }}> <label htmlFor="name">Name</label> <input id="name" value={name} onChange={event => setName(event.target.value)} /> <button>Save</button> </form> <output>{saved}</output> </Box> </Surface> <NativeOverlay><dialog>Original native dialog content</dialog></NativeOverlay> </>;}
const controller = mountCanvasApp({ outputCanvas, ordinaryHost, overlayHost, onStatusChange: (status: CanvasStatus) => { statusElement.textContent = status.mode; }, strict: true,}, <App />);await controller.enable();onStatusChange(status: CanvasStatus) delivers the structured status object
(CanvasStatus is exported). onStatus(message: string) receives the same
status as a JSON string; it is now optional and kept for compatibility.
The identifiers in the bootstrap are typed host references from your
outside-root setup, not JSX refs whose descendants the runtime may move.
mountCanvasApp creates the stable container, mounts one root and supplies
GPUHost context automatically. The root always starts in usable ordinary DOM.
Admission failure resolves to a state with mode: "dom" and an explicit reason;
invalid topology/API/lifecycle calls throw before unsafe mutation.
Normal commits synchronously register current-original native geometry and
reconcile logical capture slots. Complete cached coverage may recompose; new
slots or extent/density growth retain the last complete GPU output until prepared
textures receive fresh native-paint uploads. Old resources await healthy queue
fences or confirmed loss. Initial enable() remains explicit by default.
Membership updates and the admission policy
Section titled “Membership updates and the admission policy”The option admissionPolicy: "automatic" is compatibility-only: it behaves like
"explicit". It remains source-compatible, but no structural DOM
retirement/readmission loop runs. Both policies preserve GPU after supported
updates; neither enables it at startup or retries factual errors.
const controller = mountCanvasApp({ outputCanvas, ordinaryHost, overlayHost, onStatusChange, admissionPolicy: "automatic", // compatibility-only; same as "explicit"}, <App />);await controller.enable();// Supported membership/order/origin/size/frame/DPR updates stay in this renderer.await controller.disable(); // Cancels desired GPU intent, including queued work.Membership resources are prepared without submitting a frame. Publication
requires every current visible slot to have valid capture coverage for its CSS
extent and native pixel grid. Pending membership also blocks motion/cached
submissions that would be incomplete. Additions first publish in the synchronous
owning native paint callback after all current originals are uploaded. Retired
original nodes are not kept or cloned. Coverage provenance is separate from
current composition geometry. Native capture uses the actual default grid,
not CSS-sized downsampling; allocation growth prepares a successor.
state().publication exposes generation, pending state and capture coverage;
metrics.incompleteFramesSkipped counts withheld incomplete compositions.
metrics.mutationsIgnored counts ignored same-value mutation records (below).
Compatibility admission/readmission counters remain, but automatic attempts
and retry frames stay zero. Visibility pauses scheduling, reduced motion uses
its canonical endpoint/identity contract and forced colors refreshes native
content without revoking healthy ownership. Explicit disable/dispose, factual
unsupported native/effect boundaries and GPU errors diagnose useful DOM.
There is no error retry loop.
Physical-display, text and accessibility validation and application performance budgets are still open (open issues).
GPUHost is the context boundary, not a JSX-only canvas/reparenting
component. Its runtime is established by the explicit adapter. Do not wrap a
framework-owned existing root and assume unchanged-app compatibility.
Optional createRoot injection is for a supported React root factory. Supply
the matching optional flushSync from the same renderer entry point:
React 18’s standard flush does not flush its separate profiling root.
The demos pair public react-dom/profiling exports in production and ordinary
client/flush exports in actual development. The default adapter remains
ordinary react-dom/client plus standard flush, without demo profiling.
Matching React 18 and 19 types/builds and actual mounted behavior are recorded in
the compatibility matrix.
Authoring and controller
Section titled “Authoring and controller”| API | Contract |
|---|---|
Surface |
Original native <section>, unique nonempty ID, enrolled through layout effects; registration is StrictMode-safe |
Box |
Ordinary native <div> with compiled layout/appearance; no engine or extra capture |
NativeScroll |
Original browser scroll container; bounded axis-aligned scrolling refreshes native captures without revoking GPU |
NativeOverlay |
Portal to one fixed adapter-owned external host; original native dialog/popover controls, not GPU-painted clones |
useGPUHost() |
Returns the narrow controller inside the adapter tree |
useSurfaceRef<T>({id, decorative?, origin?}) / enroll(element, options) |
Enroll an original direct-root element, preserve authored layout/CSS; callback stable for stable options, release on replacement/null; imperative form returns an idempotent release |
useNativeOverlayHost() |
Fixed external native host for a library’s public portal-container prop |
transition(id, descriptor) |
Bounded declarative numeric motion; explicit completion/cancellation handle, no separate native animation source |
transitionGroup(items, stagger?) |
Prepare 1..8 distinct boundaries, one start epoch, index-based 0..1000 ms stagger; invalid descriptors/groups have no enrollment/motion side effects |
Presence |
Retain one React child subtree through enter/exit inside a stable enrolled boundary; no wrapper, clone or portal relocation |
setMaterial(id, descriptor) |
Validate and install a persistent colour-only material on any enrolled layer, replacing the previous one; content stays interactive, and the change is one brightness factor within interior.max (effects); update/setEmitters (emitters)/cancel/dispose handle |
setEffects(id, descriptors) |
Atomically validate 1..3 ripple/chromatic/dissolve/custom operations before replacing ownership. interaction: "decorative" needs decorative enrollment; interaction: "inert" runs a transition effect on an interactive layer whose content is held inert meanwhile (effects); explicit seconds/playback/update/cancel/dispose handles; static original DOM fallback |
defineEffect(definition) |
Frozen trusted local WGSL color-function schema, 1..4 bounded numeric parameters, layer bounds and WebGPU capability; compile before successor publication, never a remote loader/sandbox |
enable() |
Serialized structural and functional admission; the owning native canvas is laid out but invisible (opacity: 0, pointer-events: none) for the functional probe while the ordinary DOM stays presented (mode dom, initializing), then one task moves the root in under a held DOM frame (admitting); every frame from enable() to gpu shows the content (seamless first admission; metrics.admission.cover/swapMs/admittingMs); only complete application capture establishes gpu. No automatic initial enable. Engine creation has no hard deadline: after 5 s state().reason reports “WebGPU initialization slow” (metrics.admission.slowInits), ordinary DOM stays presented, the engine is adopted when it arrives and enable() then resolves; only disable()/dispose() cancel a pending init (promptly, disposing the late engine); a rejected adapter/device request still falls back with a diagnosis (admission) |
disable() |
Cancels desired/retry/admission intent; same-root DOM recovery, geometry clearing and safe fenced GPU release; repeated calls are harmless |
setTransform(id, transform) |
Replaces a layer’s transform; GPU mode updates uniforms/native geometry without React reconciliation |
setOrigin(id, origin) |
Replaces a layer’s static rotation/scale pivot (Surface and useSurfaceRef call it when their origin changes); applies immediately, including to a running transition |
animate(id, target, length) |
Interpolation of specified fields; unspecified fields stay at their starting values. A number is a fixed count of displayed frames (1..10000, refresh-rate dependent); { duration } is time-based in milliseconds. Default 300 |
requestReconcile() (CanvasRuntime) |
Public request to reconcile membership/geometry. React commits call it through layout effects and it coalesces into one microtask |
createMotion(id, descriptor) |
Numeric keyframes/timing for new applications; creates one managed native source in idle DOM or active GPU and returns explicit ownership/playback/disposal controls |
invalidate(id?) |
Explicit dirty enrollment(s), with normal native paint/input/mutation invalidation still enabled |
settle() |
Bounded completion of already scheduled capture/draw work; not a promise that future browser paint cannot occur. Content that keeps changing (a requestAnimationFrame loop, a video) does not hold it: once a paint it waited for completes, a newer paint request is not awaited |
state() |
Current capability/mode/reason, registry matrices, bounded telemetry and owned-resource ledgers |
dispose() |
Serialized final unmount/observer/listener/canvas release; refuses while healthy GPU cleanup remains pending |
Editable and top-layer admission checks include the enrolled element itself,
not just descendants. Native contenteditable (including empty and
plaintext-only values or inherited editing) and dialog/popover boundaries
retain ordinary DOM. The native ASCII
case-insensitive contenteditable="false" keyword (also FALSE/False)
does not itself require fallback, including when overriding inherited editing.
Move supported overlays through the fixed
NativeOverlay portal, not by relocating already-mounted React controls.
Original connected eligible text-entry INPUT/TEXTAREA descendants use native scroll/composition events to invalidate their enrolled capture for fresh owning native paint. Internal caret scrolling is not ancestor viewport motion and must not revoke healthy GPU or its animation ownership. Value, focus, selection and native events stay on the original control. Manual OS IME/candidate-window certification remains open.
Bounded descendant viewports, NativeScroll, enrolled viewports and the
adapter-owned root retain GPU during ordinary native scrolling. Root-relative
geometry subtracts actual ancestor scroll offsets; supported ancestor viewport
intersections feed identical transform-local native canvasClip and explicit GPU
visual clipping. Scroll requests fresh native paint; pending capture coverage
retains the last complete output. Original scroll containers, controls and events
remain unchanged. Ancestors inside the adapter root must be untransformed;
layers clipped by an ancestor must use positive axis-aligned transforms. A
rotated layer clipped only by the output frame is bounded by the canvas content
box itself once admission has proven that native hits stop at that edge. Arbitrary transformed
chains and general CSS clipping remain framework boundaries, not demonstrated
missing browser functionality. Ordinary document and untransformed axis-aligned
external ancestor scrolling refresh the cached owner-local projection without
adding window offsets, cropping textures by the page viewport, invalidating
content or cancelling motion. The browser moves/clips the whole owning canvas
and its original descendants together. External ancestor resize and style/class
changes use scoped observers; unrelated scrolling does not trigger this route.
LTR horizontal writing mode and unit CSS zoom are required.
Owner borders/padding separate native border-box geometry from GPU bitmap content
coordinates. Owner overflow clipping uses matching local native/GPU clips.
Chrome 157 treats the replaced owning canvas as non-scrollable:
overflow:auto clips but its actual scroll offsets remain zero. No speculative
owning-canvas scroll subtraction is applied or future-browser behavior promised.
Functional native clip admission needs an onscreen, unoccluded probe before GPU
exposure. Offscreen admission remains useful original DOM with an explicit
unproven/deferred diagnostic, not a missing-browser-feature claim or a successful
skipped test. Once visible, call enable() again; actual native clip hits must
pass before GPU can be exposed.
Layout supports x/y positioning, width/height, padding, gap, row/column
direction and visible/hidden/scroll overflow. Appearance supports background,
color, border color/width, radius and font size. Numeric fields are validated;
colors use the browser’s CSS.supports. These types compile to HTML/CSS layout,
not a new layout engine or an absence of browser styles. style is intentionally
excluded from authored Surface/Box props. Existing arbitrary classes/ancestor
selectors are not normalized or proven compatible.
The positioned x/y origin applies to Surface; Box remains ordinary flow layout.
Transform supports x/y, scaleX/scaleY, rotation in degrees, opacity, and a tilt.
Tilt:
- Channels:
tiltXandtiltYare in degrees, at most ±60, with the same directions as CSSrotateXandrotateY.perspectiveis the viewer distance in CSS px; it defaults to 1600 and must be at least 100. - The matrix: a tilted layer’s matrix is projective, composed as CSS composes
translate() perspective() rotate() rotateX() rotateY() scale()about the pivot.- On the GPU, pixels and native hits follow the projection.
- In ordinary DOM, the same matrix is applied as
matrix3d.
- Admission proves it: admission tests projective hit testing on the installed browser
(
metrics.admission.last.projective). Tilt on the GPU needs that proof. - Clipping: a tilted layer must not be clipped by a viewport or by its own overflow. Chromium hit-tests a
projected
canvasClipby its bounding box (crbug.com/573454036, WICG html-in-canvas#186), so such a layer is refused with a diagnosis rather than hit-tested wrongly. Rotation and scale pivot around the layer’s staticorigin, like CSStransform-origin:"top-left"(default),"center"or{ x, y }fractions of the layer’s layout size ({ x: .5, y: .5 }is the centre). The origin is not animated; transitions,animateandsetTransformall pivot around it. The pivot is baked into the one canonical matrix, so the element itself keepstransform-origin: 0 0; ordinary DOM rewrites that matrix when a pivoted layer resizes. Animation ownership (ownAnimation,ownCSSAnimation,ownCompiledAnimation,createMotion) keeps the source animation’s top-left origin and refuses a layer with another origin. Rotation is normalized modulo 360 before conversion to radians. Computed coefficients, determinant and inverse must remain finite and invertible (determinant magnitude >= 1e-8); opacity is 0..1. Huge finite scale inputs can therefore be rejected. Registration/update/setTransformvalidate the origin-aware result before mutation, preserving the prior valid layer state on rejection. Reflections are mathematically representable but not broadly validated. One canonical affine matrix drives CSS in DOM mode, inverse sampling in GPU mode and public native hit geometry. Opacity follows normal DOM semantics: transparency does not disable hit testing.
React transform prop updates are ordinary commits and may capture; use the controller for GPU-only motion. Browser hover/focus/caret and initial geometry motion can still legitimately dirty pixels. The measured 300-frame guarantee is a settled specimen interval, not an assertion that every arbitrary animation will avoid capture work.
Original-element refs and library overlays
Section titled “Original-element refs and library overlays”useSurfaceRef is an alternative to Surface, not a second registration of
the same element. Place the element directly inside the adapter’s persistent
root (context/fragment/library providers may surround it without adding DOM).
React owns the element and all descendants; neither the hook nor controller
moves, clones or creates controls. Keep ID/decorative options stable during an
enrollment lifetime. The callback identity survives ordinary rerenders; null,
replacement and StrictMode replay restore the old enrollment before reuse.
enroll validates before mutation and throws EnrollmentError with
invalid-id (blank/duplicate ID), foreign-element (not an original connected
same-document root element), duplicate-element, nested-element or
decorative-element. Decorative enrollment requires authored inert,
aria-hidden noninteractive content. It temporarily adds drawable="";
release restores the prior attribute only when the runtime’s value still
remains. It does not compile/normalize authored layout or CSS. GPU presentation
temporarily owns transform/origin/opacity; later app-written values/priorities
are diagnosed and retained instead of overwritten on release/recovery.
import * as Dialog from "@radix-ui/react-dialog";import { useSurfaceRef, useNativeOverlayHost } from "../underglass";
function App() { const panel = useSurfaceRef<HTMLElement>({ id: "panel" }); const overlay = useNativeOverlayHost(); return <Dialog.Root> <section ref={panel} className="app-panel"> <label htmlFor="value">Value</label> <input id="value" defaultValue="Original native input" /> <Dialog.Trigger>Open</Dialog.Trigger> </section> <Dialog.Portal container={overlay}> <Dialog.Overlay className="app-backdrop" /> <Dialog.Content className="app-dialog"> <Dialog.Title>Original dialog</Dialog.Title> <Dialog.Description>Outside capture, in the fixed native host.</Dialog.Description> <Dialog.Close>Close</Dialog.Close> </Dialog.Content> </Dialog.Portal> </Dialog.Root>;}App CSS must still size/position/style these elements. Neutral authored transform/opacity and existing stacking/clipping/bounds restrictions apply at GPU admission; arbitrary CSS/animation-library roots are not certified. Use the library’s public container API before its portal mounts; never move existing library DOM. Only Radix Dialog 1.3.1 Root/Trigger/Portal/Overlay/ Content/Title/Description/Close in this pilot are verified. See integration.
Declarative numeric motion and presence
Section titled “Declarative numeric motion and presence”const enter = { keyframes: [{ offset: 0, x: 60, opacity: 0 }, { offset: 1 }], timing: { duration: 800 },};const exit = { keyframes: [{ offset: 0 }, { offset: 1, x: 60, opacity: 0 }], timing: { duration: 800 },};
// Inside the adapter tree, within one stable enrolled boundary:<section ref={useSurfaceRef<HTMLElement>({ id: "card" })} className="card-boundary"> <Presence id="card" present={visible} enter={enter} exit={exit} onComplete={result => record(result)}> <Card /> </Presence></section>;
const handle = controller.transition("card", exit);const result = await handle.finished; // {status: "completed" | "cancelled", reason}// handle.cancel() is idempotent and cannot cancel a later replacement.const group = controller.transitionGroup([ { id: "card", motion: enter }, { id: "other", motion: enter },], 100);await Promise.all(group.map(handle => handle.finished));Use the hook at the top level of the containing component as usual; the JSX
above abbreviates that declaration. Descriptors reuse MotionDescriptor and
the existing compiler/easings, with one normal iteration, finite duration/delay
and total duration+delay at most60000ms (including group stagger).
Unspecified keyframe channels mean identity defaults, not inherited CSS.
Each supported numeric sample updates the canonical affine/opacity used for
native geometry and GPU composition; DOM mode temporarily writes owned CSS
and checks that it actually applies. There is no WAAPI source to double-run.
An idle enrolled boundary is required; admission/recovery/pending cleanup,
frame-count motion or competing managed/external animations refuse calls.
A group stages every presentation and revalidates the complete staged group
before starting or replacing any member. Failed preparation restores still-owned
declarations/priorities and preserves existing handles, start times and canonical
transforms. This includes style-dependent CSS failures and reduced-motion endpoints.
A replacement validates before cancellation and starts from the prior
currently sampled transform. The old handle resolves cancelled/Replaced.
Explicit cancellation restores its starting transform and still-owned DOM
declarations; completion keeps the GPU endpoint. Typed Surface DOM completion
keeps its canonical endpoint; ref-enrolled DOM completion restores authored
CSS, so author that CSS for the desired settled state. GPU-to-DOM recovery also
restores ref-authored CSS, not an unrequested permanent transform rewrite.
Reduced motion completes immediately at the endpoint, including active
transitions; disable/recovery/loss/unenrollment/disposal cancel. A hidden
document pauses a declarative transition, which resumes with the same
progress when visible (it was previously cancelled with “Document hidden”).
There is no hidden-time catch-up. Reasons distinguish these outcomes.
A Surface transform/layout update that arrives from React during a
declarative transition or animation ownership no longer throws inside the
commit: the React-supplied transform becomes the resting transform applied when
the motion ends (or the ownership base on release).
Presence starts its enter/exit motion in a layout effect, so there is no
unanimated first frame. It is controlled by present; initially present content is already
settled and does not enter-animate. False retains the same React subtree
during exit; completion or cancellation removes it. True during exit reverses
from the sampled state without remounting that subtree. True after removal
mounts one new React subtree and enters. A parent unmount cancels motion and
suppresses stale completion callbacks. Keep its ID/runtime stable; changing
descriptors alone does not restart a transition. Content remains native
interactive DOM until React removes it; opacity 0 does not disable focus/hits.
Presence animates the enrolled boundary, so place only the intended subtree
inside it and do not run a second transition owner on that same boundary.
Content mutation and native editing dirty capture. Supported capture bounds, membership, layout and DPR updates retain GPU with complete prepared successors; internal editor scrolling refreshes native contents without explicit readmission. Unsupported authored clipping and unreconciled enrolled/ancestor viewport scrolling remain disclosed framework gaps, not missing-browser-feature claims. This is not a general CSS scroll/route/shared-element/spring engine. No controls are hidden or duplicated to simulate exit retention.
Opt-in operation measurements
Section titled “Opt-in operation measurements”MountCanvasOptions.onMeasure receives synchronous samples with
kind, at (performance.now milliseconds), durationMs, phase
(admission or content) and optional layerId/RGBA bytes.
Kinds are captureMs, readbackCompareMs, uploadMs, frameWorkMs and
frameIntervalsMs. The runtime retains no extra event array; its normal
last 256 histories/cumulative counters remain bounded.
Use a cheap, nonthrowing callback and bound any external collection. Throwing
is not silently swallowed and can trigger the renderer’s explicit failure
path. Callback/profiling overhead can perturb timing. Capture timing excludes
the native paint wait; readback plus comparison is measured separately;
upload/frame timing is synchronous public CPU work, not completion/GPU
execution. phase separates admission work from content work.
See measurement definitions and raw evidence.
With an instrument material, instrumentGpuMs samples (layerId, phase: "content") carry the GPU time of one
layer’s mesh pass and post chain in one frame, and with emitters, emitterGpuMs samples the GPU time of one layer’s face pool pass,
both from Babylon Lite’s per-task timings when the device has timestamp queries.
Input events and captures
Section titled “Input events and captures”A native control’s value is not a DOM mutation (a range thumb moves with it), so core queues a capture of the enclosing layer
for every input event in a layer, as it does for focusin and focusout; DOM that a handler writes is captured by the
mutation observer as usual. A presenter that draws a control’s value itself and hides the control’s native paint opts out
per element with the attribute data-underglass-gpu-value (GPU_VALUE_ATTRIBUTE, exported by @underglass/core) on the
element that receives the event:
- An
inputevent whose target carries it queues no capture. It is counted:state().metrics.inputCapturesSkipped, andstate().metrics.perLayer[id].inputCapturesSkippedfor each layer that contains the element or is contained by it (asqueueCaptureattributes), so the per-layer sum can differ from the global count. - Nothing else changes: attribute, text and child changes a handler makes still recapture through the mutation observer, and every
element without the attribute captures on
inputexactly as before. - The mark is a promise, and the presenter must guarantee all of it:
- The element’s own paint does not depend on its value. A hidden thumb is not enough if a visible spinner, text, caret,
selection, track fill or
datalisttick still shows the value. - No CSS keyed on the element’s state restyles it or anything else without a DOM write (
:valid,:invalid,:in-range,:out-of-range,:placeholder-shown,:has(...), sibling selectors): such a change repaints with no mutation, and only aninputcapture would have caught it. - Text, number, select and checkbox controls are never marked: their paint shows the value.
- A native repaint that remains (a focus ring, hover) does not need the
inputcapture: it reaches the GPU through the browser’s paint event (changedElements), the documented path for hover and focus repaints (focusin/focusoutalso queue one). A marked control with a value-dependent paint would show stale pixels, which the contract forbids.
- The element’s own paint does not depend on its value. A hidden thumb is not enough if a visible spinner, text, caret,
selection, track fill or
- The Strata JogWheel’s helper sets the mark on the wheel’s input with the dimple’s
data-strata-dome(while the GPU draws the dimple; its thumb is never painted) and removes it where the dome is removed, so a flick of 49 steps costs 0 captures for its input events (36 to 38 before). A Knob or Fader writes its value to a custom property on its root for eachinput, which is a DOM write that recaptures anyway, so they are not marked.
Canvas inside a layer
Section titled “Canvas inside a layer”On the tested build (Canary 158.0.8112.0) HTML-in-Canvas captures a nested <canvas> only when the canvas is itself
content="drawable"; an unmarked one is captured only if it is the first thing attached to its layer and is otherwise the layer background,
before and after a redraw (issue #184; measurements in upstream/canvas-in-drawable.md, pinned by
tests/browser/core-canvas-content.test.mjs). Marked, every variant is captured in every arrangement, whether the mark is set before
or after attaching or followed by a resize and redraw: 2D (also willReadFrequently), WebGL (also preserveDrawingBuffer),
bitmaprenderer, OffscreenCanvas-transferred, deep, hidden then shown, after a painted div or img, last of three,
insertBefore. The mark changes nothing else on the canvas: layout, getContext, toDataURL, its DOM pixels and native hits are
the same.
So core does it for you, with nothing to opt into:
- While GPU presents, every
<canvas>inside an enrolled layer is setcontent="drawable", at admission and for a canvas added (or a layer registered) later; the next paint captures it like any content, with fresh pixels after a redraw or resize. - The author’s own
contentvalue is remembered and restored (absent, empty and any other value exactly) when GPU ends (disable(),dispose(), any diagnosed fallback), when the layer is released while the element stays in the DOM, and when the canvas leaves every layer (removed, or moved outside the root). A canvas moved between two enrolled layers stays marked. Do not rely on the attribute: it readsdrawableonly while GPU presents. - A value the author or a framework writes to
contentwhile GPU presents (a React or Vue prop that changes,setAttribute, a removal) is overwritten by the mark at once and remembered instead: it is what comes back when GPU ends. - Cost: one attribute write per canvas per marking event. It is an ordinary DOM mutation, captured like any other (it shows in
state().metrics.captures, not attributed separately), and it costs one recapture of the layer. - Not marked: a canvas outside every layer; a
<canvas>that hosts drawable children (nested HTML-in-Canvas, which draws its own subtree) or sits inside one; and a canvas inside an open shadow root, whichquerySelectorAlland the runtime’s mutation observer do not reach. Limit: such a canvas is captured only as an unmarked canvas is (the first thing in its layer; blank behind other canvases): setcontent="drawable"on it yourself, and core leaves it alone. The nested-host canvas’s own bitmap is captured unmarked. Both are pinned incore-canvas-content.test.mjs. - Limit: an
OffscreenCanvas-transferred canvas (transferControlToOffscreen()) is captured correctly when a paint happens, but its commits raise nopaintevent (a redraw of every other kind raises one), so its pixels stay stale untilcontroller.invalidate(layerId)is called; call it after drawing (the matrix incore-canvas-content.test.mjsdoes, and a test pins the stale state so a browser fix is noticed). Core cannot detect such a canvas without a destructive probe (getContextortoDataURLon a canvas it does not own), so it neither refuses nor recaptures them on its own.
Same-value mutations are ignored
Section titled “Same-value mutations are ignored”A write that leaves the DOM as it was costs no capture. The runtime observes the root with attributeOldValue and
characterDataOldValue, and when a batch of records is delivered it ignores each record whose oldValue equals the node’s
current attribute value (getAttribute, or getAttributeNS for a namespaced attribute) or current data. This covers
setAttribute(name, sameValue) (including style and class), classList.add(existing) (Chrome re-sets the class attribute: one record, measured
as ignored) and node.data = sameData; element.style.x = same produces no record at all (measured: 0 records, 0 ignored). The rule is exact for a batch of writes v0, v1, …, vn to one value: every record
holds the value it replaced, so all are equal to the current value only if nothing changed, and any change leaves a record with an
old value different from the current one. Therefore A to A is ignored, and A to B, A to B to A and A to B to C are captured. A record whose old value is
null (an attribute added), an attribute that is now absent, and every child-list record are never ignored. A batch that is entirely ignored also skips
the policy refresh and the reconcile it would have requested.
state().metrics.mutationsIgnoredcounts ignored records, andstate().metrics.perLayer[id].mutationsIgnoredcounts them for each layer that contains the target or is contained by it (asqueueCaptureattributes), so the per-layer sum can differ from the global count.- Writes that act on a same value are never ignored, because the pixels can change although the DOM string does not:
src,srcset,srcdoc,href(alsoxlink:href),dataandposter(a re-fetch, re-decode, re-navigation or re-resolved reference) andwidth/heighton a<canvas>(the browser clears the bitmap on every set, even to the same value; on other elements they stay eligible). The list isSIDE_EFFECT_ATTRIBUTESinpackages/core/src/mutations.ts, with the reason for each entry. - Records for the external ancestors’
styleandclasswatch (the viewport geometry watch) are counted inmutationsIgnoredtoo, although they would never have captured (the capture gate only admits targets inside the root): the global count can exceed what a layer’s own count shows. - A namespaced attribute (
xlink:titleon an SVG<use>) compares throughgetAttributeNS: a same-value write is ignored. - Writing
textContentis not a same-value write (it replaces the text node: a child-list record) and captures. - A component that writes a value on every frame should still guard the write: it costs one record and one comparison per write, not a capture.
The instrument material
Section titled “The instrument material”setMaterial(id, { kind: "instrument", scene, bounds: "layer", interaction: "material", media: "identity", fallback: "static" }) draws the Instrument level (design note, Rendering Model part 2D).
The scene is a version 4 strata-scene with kind: "instrument":
| Field | Contract |
|---|---|
extent, outset, backdrop, light, bevel, shadow.strength, interior, state, focus, primitives |
The face, shaded by core’s relief pass as version 1; light adds intensity (1.6) and ambient (0: the studio is the fill) |
camera |
Required: distance (D, at least 100) and vanish; the vanishing point is drawn at the canvas centre |
environment |
Required: { source: "studio", strength (0..3), studio? }, the studio generated in code and turned to the key light |
materials |
At most 16 recipes: base (colour, roughness, metallic, direct), coat, reflectance (dielectric F0 only), emissive (strength, colour), sheen, iridescence, brush with a hairline grain; glass (ior, tint, absorption, reflection) and dispersion for glass parts; subsurface validated, not drawn yet |
profiles |
At most 64 shapes by name: lathe (points: [[r, z, nr, nz], …], segments), prism (width, length, radius, height, taper, chamfer), extrude (outline: [[x, z], …], width), mesh (explicit triangles, inline or a glB/glTF reference: custom parts) |
objects |
At most 256 (with primitives, 256 in all): { id, rect, bounds: { rect, radius }, t (clamped to 0..1), parts, relief }. A part is { kind, profile, at, pose: { from, to }, material, captured, glass, stand, finish, caster, state }; a pose mixes travel, slide, turn and tilt about a fixed axis and pivot; only parts that travel may be captured |
emitters, protected, feather |
Lights and the guard, as for every level (emitters); the parts are lit by Babylon Lite clustered lights |
shadow.map |
The PCF map of parts on parts: `{ size: 2048 |
post |
{ smaa, bloom, taa, contact, ssgi }, booleans, default true, true, false, false, false (the defaults when omitted) |
a part’s emitter |
An index into emitters: the part is that emitter’s lens and feeds the bloom while the emitter gives light; on a glass part the lens also glows through its glass |
a part’s glass |
"lens" or "window": the part is drawn by the glass shader (below). A lens may have a stand ({ radius, colour }, the polished part it stands on, which its reflection sees); a window may have a finish zone (true: band 0.3, horizon 0.25, or { band, horizon }) |
environment.page |
{ enabled, size, height }, on by default (512, 300): the layer’s page cube for its metals and coats (below); enabled: false leaves the studio alone |
- Drawing. Each instrument layer has a Lite scene on the shared engine, drawn right after the layer’s pass:
analytic face shadows of the parts; then the parts (thin-instanced per kind, GPU-culled) into an offscreen target with
their own depth, the post stages the scene turns on (TAA, contact shadows, SSGI, SMAA, then the bloom of the lit
lenses), and the captured caps after the chain; one composite lays the result over the face, multiplied by a mask of
the objects’
boundsand the layer’s clip (so no stage draws outside a control’s box) and by the layer’s opacity, as one group. Captured text is never filtered.handle.update(scene)with newt, states or layout writes instances in the same frame, with no capture, upload or hit-geometry change; so do more or fewer objects of existing kinds. New profiles, materials, part kinds, a new light or environment, newpostflags orshadow.map, or a stage larger than the map’s depth window (a half-diagonal past 3,840 px) rebuild the scene; meanwhile the layer draws its objects at the Relief level. - Glass (core work item 4) is a custom shader, not Lite’s transmission: per pixel it loads the layer’s capture and traces Rendering Model 2D.6 exactly. A lens refracts the capture in at its dome and out at its flat bottom, one index of refraction per colour channel (
dispersion), and a window shifts the capture against a flat pane; both add the Fresnel reflection of the studio, of the lens’sstandand of the face, tinted and absorbed byglass.tintandglass.absorption. Text under a window, inside the scene’sprotectedrects grown by 2 px, and a window’s flat region outside its finish zone are the capture texel: for opaque texels a twin of the window in the caps target keeps SMAA from touching them, and translucent texels (alpha below 1) follow the face pass. Glass is drawn in the same target as the other parts, so the chain, the clip and the opacity apply; it neither casts into nor receives from the shadow map, casts on the face like any part unless itscasterisnull, and is not lit by the clustered lights (a lit lens glows through itself). Glass roughness is not drawn. If the glass cannot be built, the glass parts are left out (the face shows through) andlayers[].glassreadsfallback. - The page probe. Each layer with
environment.pageon holds a cube texture (sizetexels per face, a mip chain, 16.8 MB at 512) filled on the GPU from its capture and the studio, which Lite binds as the specular environment of the layer’s PBR materials with box projection: metals and coats reflect the faceplate print and neighbouring controls, and the studio where the page does not reach. It is refreshed at once when the layer’s capture is uploaded again or its texture is replaced, and when the layer moves (its placement quantised to a quarter of the cube’s texel footprint, about 0.29 px at 512) at most once per 100 ms while it keeps moving or after a resize or DPR change that keeps the capture texture (a replaced texture refreshes at once), with one more when it settles; never otherwise (poses, emitters and idle frames refresh nothing); at most four layers per stage hold one (state().instrument.probes.budget), the others reflect the studio alone withlayers[].probe.status: "budget". A probe that cannot be built is left out the same way (status: "failed"with itsreason), GPU on; one whose refresh throws turns the page off and the layer is rebuilt without it (the cube is released, the budget slot is free for the next build). - The shadow map is a PCF map from the key light, its depth window centred on the stage, the bias given in world px. Every part casts and receives it except captured caps; the face never receives it. It is rendered again only in a frame whose parts move.
- The output transform: core sets the parts’ image processing to exposure 0.8 and contrast 1.2 (tone mapping off) after it loads the studio, the values Babylon Lite’s HDR loader sets and the Rendering Model’s 2D.5 output transform.
- Containment. Every vertex of every part, over the whole travel, must project at least 0.5 px inside
bounds, andboundsmust lie inside the layer’s box. An object that does not is drawn as itsreliefprimitive (default: a raised box over its bounds). A layout change reaches core before it reaches the scene’s producer: core’sResizeObserverreconciles and draws in the frame the box changes (a media preference such asprefers-contrastcan change a border and so the layer’s box), and the producer’s own observer answers one frame later. The check always runs against the layer’s box as it is, and the drift check against the layer’s placement; one verdict is held: an object that the same scene (sameextentand geometry) already passed, at the box it was built for, and that now fails only the bounds check, keeps that verdict until the next scene. Where the layer sits on the stage (it moved with a sibling, a stage resize) does not invalidate it. What does: a different sceneextentor geometry (the next scene, built from the new layout, is checked against the new box), a changed structure (the layer is rebuilt), or an object that was already stepped down. A box that grows, or shrinks with the bounds still inside, holds nothing. While something is held the layer reports the warninglayout: the scene was built for W × H px and the layer is W' × H'; N object held to its containment verdict for that box until the next sceneandlayers[].layoutHeld({ objects, frames }: the objects held in this frame, and the frames held in all, a counter a consumer can watch). The composite is the layer’s current box plus 1 px, so the held parts are cropped at the new box and never cover what lies outside it. A new scene, or one built for the box it is checked against, whoseboundsleave that box is stepped down as before (containment,its bounds leave the layer's box); nothing is delayed or hidden. A producer that never rebuilds keeps the stale scene, the warning and the count. - Budgets. 2,048 parts per layer and 6,000 per stage; above either, the layer is drawn at the Relief level. More than 16 part kinds per layer is reported; more than 64 steps the layer down. The format accepts up to 8,192 parts.
- Material cap. A layer draws with at most 16 materials (
INSTRUMENT_MATERIALS). A scene naming 17 to 64 is valid: the whole layer is drawn at the Relief level, every object stepped down with reasonbudget(detail:17 materials in the layer, above 16) and amaterials:warning, GPU on. A scene naming more than 64 (INSTRUMENT_MATERIALS_CEILING) is a validation error: 64 is a format limit, the same bound as profiles and protected rects, not a budget. The face’s own Relief binding holds 16, so past the cap it binds the materials the face’s primitives use; when those are more than 16 the face draws the first 16 in scene order and the rest in the first (the layer is stepped down already, anddetailaddsN face materials, above 16, with amaterials:warning of its own). An entry naming a material the scene does not hold is refused as at 16 or fewer.@underglass/ui/gpuexportscreateKeyRegistry,KeyBodyandKeyRegistry(public: loops that draw key-like objects register their bodies so the helper can trim them to fit; seegpu/instrument-keys.ts), withINSTRUMENT_KEY_BODY_MATERIALS,INSTRUMENT_PUSH_MATERIALS,STRATA_INSTRUMENT_MATERIAL_CAP,StrataGpuDiagnosticsandStrataGpu.diagnostics(panel). Each layer reportsmaterials: { count, cap }andstate().instrument.budgets.materialsPerLayeris the cap. - Diagnostics.
state().instrumentlists each layer’sstatus(ready,pending,stepped-down,failed), objects, parts, drawn parts, kinds,materials({ count, cap }),steppedDownentries ({ object, reason, part?, inset?, overshoot?, detail? }, reasonscontainment,budget,compile,pending,transform,order-unavailable,post), warnings and GPU time (lastMs,p50Ms,p95Ms); each layer’sprobe(statuson,off,budgetorfailedwithreason; with a probe itssize,bytes,refreshesand the GPU time of the refreshes) andglass(statusnone,onorfallbackwitherror);state().instrument.probes(budget,live,builds,failures,refreshes,bytes); the stage’s parts; the timing status; and the compile counters (builds, failures, deferred successor deadlines).state().instrument.postlists per layer each stage’s status (on,off,idlefor bloom with no lit lens,failedwith itserror), the map (size,biasPx,biasin Lite’s units,darkness,window,casters,renders: the controller’s count of frames that flushed a caster, an estimate of Lite’s refresh rule,timedRenders: the frames Lite’s timings saw the map task in, a sample), its lenses and lit lenses, and the GPU time per stage (face,map,mask,parts, the stages,caps,composite:lastMs,p50Ms,p95Ms,samples,total); and the shared targets (format,width,height; a target no layer uses may report its last size).state().instrument.resources.mapTexturescounts shadow map textures released with their scenes (destroyed) and any that could not be (missing, warned once). A stage that fails to build is left out and reported; a failed registration is rebuilt once without the optional stages.postmeans exactly: the chain’s target, mask or composite could not be built, or the minimal chain (the chain without its optional stages: the rebuild after a failed registration, or the first build when no optional stage is on) failed to register. Any other build error (a mesh, a shader material, the studio) stayscompile. Either way the reason names the cause (the original error’s message). The layer is then drawn at Relief.state().materialslists{ kind: "instrument", objects, parts, interior, degraded }. - Compile sequencing. A successor composition that starts while a mesh build compiles starts its 1.5 s deadline when the build is done or after 3 s, whichever comes first; a build that takes over 10 s steps its layer down.
- Never DOM for an instrument reason. Containment, budgets, build failures and slow builds (10 s) step objects down to Relief with GPU on. A GPU error (validation scope, device loss) still recovers to ordinary DOM.
- Limits: a layer whose matrix is not a similarity (shear, non-uniform scale, a tilt) draws its objects at Relief;
between 0 and 1, the meshes blend as one group over the layer’s already faded face: under an opaque part the canvas is
α · P + (1 − α) · (α · F + (1 − α) · B)(Fthe face,Bwhat lies beneath the layer), not the layer’s true group opacityα · P + (1 − α) · B, so face content under a part shows through atα (1 − α).
Custom parts
Section titled “Custom parts”A mesh profile is a part of any shape (design note, Rendering
Model 2D.2). A part uses it with kind: "custom" (or the kind of the part it replaces) and keeps everything a parametric
part has: material (the scene’s recipe, so themes apply), at, pose, caster, emitter, finish, state and glass (the
glass shader draws the mesh); it carries no captured content and no stand. Units are the profile’s: part-local px, x right, y down, z up from the face.
profiles: { // inline: the triangles are in the scene bar: { type: "mesh", positions, indices, normals: "compute" }, // or normals: [...]; uvs optional // a reference: core loads it outside any frame cap: { type: "mesh", source: { url: "/parts.glb", mesh: "Cap" }, bounds: { low: [-26, -26, 0], high: [26, 26, 15] }, scale: 1.4 },},| Field | Contract |
|---|---|
positions, indices, normals, uvs |
Inline. At most 16,384 vertices and 32,768 triangles; every value finite (within ±10,000), indices below the vertex count, no triangle that repeats a vertex; one normal per vertex or "compute"; planar UVs when omitted. Refused with the path and the reason |
source |
A reference: { url, mesh? } (fetched by core) or { data: ArrayBuffer, mesh? }; a glB, or a glTF with embedded buffers; mesh names a glTF mesh or the node holding it (omitted: the file’s only mesh) |
scale, offset |
A reference: glTF units to part-local px (default 1), and a placement added after the axis change (default [0, 0, 0]). A glTF vertex (x, y, z) becomes (x, z, y) · scale + offset: the model stands on the face, its front towards the bottom of the screen |
bounds |
{ low, high } in part-local px after scale and offset. Required on a reference, optional inline; core checks every vertex against it (1/1000 of its size and 0.01 px of slack) |
pivot |
The default pose.pivot of a part of this profile whose pose gives none |
- Winding and normals. A triangle is wound against its vertex normals, whatever order it was authored in; computed normals come from a closed mesh’s winding (a mesh wound the other way is reversed first), so no top is lit as if it faced inward.
- Validation is cached. An update validates its scene each time, so an inline mesh is validated once per input: pass the
same arrays (or the same profile object) and nothing is checked again; pass new arrays to change it. A URL reference with
the same
url,mesh,scale,offset,boundsandpivotis one profile across updates. - Containment. The check of every part runs on the mesh’s own vertices over the whole travel. A mesh that cannot stay in
its object’s
boundssteps the object down to its Relief primitive (reason: "containment",part: "custom", with the inset and overshoot): refused, never clipped. - Loading. A reference loads once, outside any frame, with Core’s own glB reader (positions, normals,
TEXCOORD_0, indices, node transforms; no Draco, meshopt, quantization, sparse accessors, external buffers or skins; materials, textures, animations and morph targets are ignored). Until it is ready, and if it fails, the part is not drawn and is not checked (or, with afallback, its stand-in draws: below); every other part of the layer draws, no scene is rebuilt and no object steps down for the load itself: the file’s triangles replace the mesh’s placeholder in place (resizeMeshGeometry) in the frame that binds them, the frame the finished load asks for. (What the real triangles then show is checked like any part’s: they may refuse the object’s containment, or push the layer’s custom triangles over their budget.) A failure (a 404 or other HTTP status, a server answering with an HTML page, a body over 8 MiB, 15 s, a malformed file, a mesh that is not there, a cap, a mesh that leaves its declaredbounds, the asset budget) leaves the part out and reports its reason; GPU stays on. - Stand-ins (
fallback). A custom part may carryfallback: [part, …], up to 8 parametric parts (any of its ownkind,profile,at,pose,material,caster,state; a mesh profile or a nestedfallbackis refused). Core draws them while the part isloadingand after itfailed, and stops drawing them in the very frame it draws the mesh (the in-place swap above), so there is no frame with both and none with neither. Validation expands each into a part markedfallbackForplaced before its custom part (validated scenes validate again), so they count as parts in the budgets.customParts[].showingreportsmesh,fallbackornone. The Strata knob’s custom cap uses it with the built-in cap, chamfer and pointer. - Cache. Parsed triangles are cached per engine by
url,mesh,scaleandoffset(a buffer by identity) under 32 MiB (the budget bounds this parsed cache; Lite’s own retained CPU copy and GPU buffers are not counted); the least recently used entry no scene uses is evicted first, and a load that would pass the budget with every entry in use fails (budget). A failed entry is forgotten once no scene uses it, so a scene that asks again retries. The store goes with the controller (in-flight requests are aborted); the geometry on the GPU goes with its scene. - Budgets. The triangle budget counts each part’s base mesh, not the glow copy of a lens or the exact twin of a glass window. Custom parts count as parts (2,048 per layer, 6,000 per stage) and each distinct mesh with each material is a
part kind. Above 1,000,000 drawn triangles of custom parts in a layer (instances times triangles), the last objects that
hold custom parts step down (
reason: "budget", the triangle count indetail) until the layer fits. - Casters. Without
caster, a mesh casts the xy extent of its posed convex hull at its mean height, as round as the hull, with the height capped so that the shadow stays inside the object’sbounds(the Fader’s rule; a footprint within 1 px of the box leaves no room for a shadow, so the cap is 0 and the part casts nothing); an explicitcasteris honoured as given. - Diagnostics.
state().instrument.layers[].customPartslists each custom part (object,part,profile,source,status:loading,readyorfailed,error,vertices,triangles);state().instrument.assetsthe store (entrieswith theirbytes,fetchMsandparseMs,bytes,budgetBytes,loads,hits,failures,evictions,inFlight, anduploads: how many times, and how long, a mesh’s geometry was replaced in place);state().instrument.budgetsgainscustomTrianglesPerLayer. - Not in this version: textures from the file, animation, skinning, and the design language’s anchor fitting
(
fitCustomPart): fit withscaleandoffset, or in the helper.
Emitters and the pointer light
Section titled “Emitters and the pointer light”Lamps, LEDs, lit rings and the pointer light are emitters: point lights a version 4 scene carries, at the Relief,
Objects and Instrument levels (design note,
Rendering Model 2D.4). The relief material takes them in a version 4 scene without a camera, the objects material
in one with a camera (both kind: "relief", the default), the instrument material in its own; versions 1 to 3 refuse
them, and a version 4 scene without emitters shades as before.
| Field | Contract |
|---|---|
emitters |
Up to 1,024 { position: [x, y, z], colour: [r, g, b], strength, radius, reach, face }: layer px with z above the face, colour channels 0..1, strength at least 0, radius above 0, reach "outset" (default: the layer’s box and outset) or "layer" (the layer’s own captured content only: the pointer light), face 0..1 (default 1: the share of strength on the face; parts take all of it) |
protected |
Up to 4,096 rects [x, y, width, height] (the layer’s text runs), or "extent". More than 64 protect the layer’s whole box (reported) |
feather |
0..16 px, default 4: how far outside a protected rect the guard eases off |
- Drawing. A lit layer gets a face pool pass right after its own pass (before its Instrument parts): over its box
and its
outsetit multiplies what is on the canvas by1 + E, withE = I · face · colour · cos θ / (π d²)windowed to 0 atradius, so a lamp lights its faceplate and the page or the panel beneath past its edge (easing out over the outer half of the outset). The relief shading adds each emitter’s highlight on bevels, object sides and domes; at the Instrument level the parts are lit by clustered lights with the same values. - The guard. Inside a protected rect the face’s whole change (the relief, the part shadows and the light together)
stays within
interior.max: the light is capped and part shadows are scaled there, easing back overfeather. A protected box ("extent", or more than 64 rects) has its caps per cell of a 16 × 9 grid, so the part shadows and the light stay everywhere at mostinterior.maxfrom the face. A layer adds no light to the text of the layers beneath its outset (their protected rects, mapped into its space): each layer’s text takes light from its own layer only. Part shadows inside protected areas are guarded whether or not the scene has emitters (2D.4’s face factor). Not held: text on bare ground (inside no relief entry) takes the relief’s cast shadows at full strength, as Relief never guarded it; caps step at the cells’ borders; under a protected box the relief highlights are off. - Budget. A layer draws the 64 emitters that give light and reach furthest into it (scene order on ties); the rest are dropped and reported, never a validation error, and GPU stays on.
handle.setEmitters(emitters)replaces a version 4 scene’s emitters without validating, repacking or rebuilding anything else: the path for a light that moves every frame. It writes uniforms and clustered light data only: no capture, upload or hit-geometry change. On a version 4 scene that has noemittersfield yet,setEmitterstakes the full validation path itself and adds the field (for an Instrument scene, one rebuild of its mesh scene). Core draws every frame in which a light was written, whatever the writer’s idiom (after such a draw it asks for a lead frame).- Diagnostics.
state().emittershas the budget (64),order("core", or why the face pools are off), the task timing status and, per layer with emitters or protected rects:emitters,lit,outOfReach,drawn(scene indices),dropped({ index, margin }, margin in px of reach),pass(drawn,idleoroff),feather,beneath({ rects, merged, overflow }: lower layers’ text under its outset, merged into one box when it does not fit, or the whole outset unlit when nothing fits),guard(rects,extent,overflow,capswith each rect’slightandshadowcaps,cellswith each cell’s caps for a protected box) andgpu(lastMs,p50Ms,p95Ms,samples,total).state().instrument.layers[].lightsreports whether a scene has its clustered lights and how many are lit.state().resources.lightPassescounts the face pools alive (they go with their passes after a queue fence or with the engine), andstate().metrics.lightPassesDisposedcounts those released. - Instrument scene limits changed:
emitterstakes up to 1,024 (was 64),protectedup to 4,096 rects (was 64), andfeatheris 0..16 (was 0..1,000). - Not in core: tracking the pointer,
pointer: fine, the fade,prefers-contrastandforced-colorsare the application’s (the Strata GPU helper’s); core draws what the scene says. Emitters cast no shadows and never move the key light.
Supported boundary, budgets and failure
Section titled “Supported boundary, budgets and failure”One engine serves the application surface; there is no per-component engine. Each layer is drawn as its own blended pass on that engine. Default limits are 32 layers and 2,000,000 captured pixels. Layer budget is 1..64; GPU output is additionally limited to four times the captured-pixel budget and the public configured device’s dimension limit. Functional admission places its probe inside the visible part of the output canvas, so a partially scrolled page can enable GPU; a fully offscreen canvas is still refused with the same unproven/deferred diagnostic. Functional admission requires at least 16 CSS pixels on each surface axis for its independent known-color marker. It uses the same single engine and legal capture/upload path as content, plus one bounded public GPU readback. Transparent application pixels are not a failure heuristic. In-viewport probe occlusion/hit disagreement refuses admission. Offscreen native clip witnesses are explicitly unproven and defer admission in useful original DOM until visible and explicitly enabled again; applied-matrix checks cannot substitute for hits. The output is not revealed until application geometry validates too. An oversized source is refused even if scaling would fit its visual rectangle: the owning capture bitmap must contain the untransformed source.
Nested/zero/detached/foreign layers, unbounded overflowing capture content, unsupported
clipping/scrolling/top-layer descendants, custom stacking and out-of-frame
transforms use refusal or diagnosed DOM recovery. Supported dynamic membership,
size and DPR updates reconcile current original geometry and prepared complete
successors in the same renderer/root without healthy DOM recovery or explicit
reentry. Missing/new/grown capture coverage retains the last complete GPU output
until fresh owning native paint can publish the complete successor. This bounded
behavior is not physical seamless-presentation or universal layout qualification.
The bounded adapter-root/enrolled viewport route subtracts actual ancestor scroll
offsets and intersects axis-aligned ancestor client boxes. The same transform-local
clip is registered with public canvasClip and applied explicitly in the GPU
shader: native clips alone neither render pixels nor request paint. Native scroll
requests fresh capture while original containers/control state remain intact;
cached complete-frame and resource fences remain unchanged. Supported ancestors
are observed for resize/mutation and read on reconciliation, not globally each
animation frame. Obsolete clips are replaced explicitly and retired originals
clear native geometry. Ordinary external document/axis-aligned ancestor changes
are projection-only; internal viewports and native editing still request fresh
capture. Transformed ancestors, RTL/writing-mode/zoom and mixed visible/clip axes
remain diagnosed framework boundaries, not missing browser-feature proof.
No general CSS clip/scroll engine or physical continuity qualification is implied. Animation
translation is restricted to the explicit contract below.
The adapter alone may move its stable container with moveBefore. Never move
React-created descendants, change the root/portal target, detach/populate the
destinations or use an append/remove fallback. Final disposal removes the
owned capture/output canvases; ordinary/overlay destinations remain.
Healthy timeout is not device loss: retained cleanup keeps the original DOM
usable and blocks reentry/final disposal until a confirmed fence or actual loss
permits GPU release. Admission readback promises must also settle: loss does
not falsely cancel an outstanding SDK request. state() exposes
resources.probeElements, resources.probeReadbacks and separate
metrics.admission counters/last successful proof. Normal content counters
exclude probe captures, copies, uploads and frames. A never-settling readback
can keep reentry/final disposal blocked; no unsafe forced cleanup is exposed.
See architecture.md for ordering and
verification.md for tested versus untested behavior.
Transactional animation ownership
Section titled “Transactional animation ownership”Prepare ownership in idle DOM or while GPU is active (not while admitting,
recovering or with pending cleanup). In GPU mode the layer’s GPU declarations are
lifted only for the synchronous verification below and restored before any
rendering opportunity. A layer must have
neutral controller transform/opacity and explicit inline transform: none,
opacity: 1, with a computed top-left origin. The adapter rejects important
transform/opacity overrides, individual translate/rotate/scale, overlapping
subtree effects and a source whose computed presentation disagrees with the
compiled effect. CSS requires explicit raw-rule provenance; resolved
getKeyframes() alone cannot establish dependency freedom.
The stylesheet !important pre-scan no longer throws for cross-origin sheets,
CSS nesting, @import, @container and similar; it skips what it cannot
inspect, and per-frame computed-style validation remains the guarantee.
Matching stylesheet important transform/opacity overrides are also rejected,
even when the current phase coincidentally matches the static override.
Initial validation inspects readable document/adopted sheets with a 4096-rule,
eight-level budget; inaccessible/imported sheets, nested selector rules,
scoped/dependent groups and matching important rules inside media/supports/layer
groups conservatively reject ownership rather than
pretending precedence is proven.
Numeric motion for new applications
Section titled “Numeric motion for new applications”import type { MotionDescriptor } from "../underglass";
const descriptor: MotionDescriptor = { keyframes: [ { offset: 0 }, { offset: 1, x: 70, y: 20, rotation: 10, scaleX: 0.85, scaleY: 0.85, opacity: 0.5 }, ], timing: { duration: 1000, delay: 100, iterations: Infinity, direction: "alternate", easing: "ease-in-out" },};const motion = controller.createMotion("editor", descriptor);motion.seek(100);motion.own();await controller.enable();motion.play();motion.pause();motion.seek(600);await motion.release(); // Same-phase native DOM source remains available.// Or keep GPU: freeze this layer at its current pose and dispose the source.// await motion.release({ freeze: true });await motion.dispose(); // Cancels this managed source, not a later replacement.MotionKeyframe uses numeric CSS pixels/degrees/scales/opacity and explicit
offsets; omitted channels mean identity, not inheritance or previous-frame
values. MotionTiming uses milliseconds and the bounded subset below.
Defaults are delay 0, one iteration, normal direction, linear easing and fill
both, including the internal native source. The descriptor is copied and
entirely compiled before any source is created. No Element.animate call or
transform-string authoring is required in the application.
MotionHandle delegates play/pause/seek to the GPU owner while owned and the
native source otherwise. own() acquires or returns the current ownership;
after release it may acquire again through the same validation, in DOM or GPU. Its
state().released means ownership is inactive, not that the source is canceled.
release() restores DOM without deleting the source; dispose() cancels the
specific managed source. Enrollment removal/runtime disposal also cancel it,
after safe source restoration when GPU-owned. runtimeResources.nativeMotions
counts these live sources. The explicit nativeAnimation reference is an
interoperability/diagnostic escape hatch; external mutations while owned are
subject to the same conservative conflict policy. It is not necessary for
numeric motion authoring.
Separate existing CSS/WAAPI compatibility adapters
Section titled “Separate existing CSS/WAAPI compatibility adapters”const source = originalElement.animate([ { transform: "translate(0px, 0px) rotate(0deg) scale(1)", opacity: 1 }, { transform: "translate(70px, 20px) rotate(10deg) scale(0.85)", opacity: 0.5 },], { duration: 1000, delay: 100, iterations: Infinity, direction: "alternate", fill: "both", easing: "ease-in-out" });source.pause();source.currentTime = 100;const playback = controller.ownAnimation("editor", source);await controller.enable();playback.play();playback.pause();playback.seek(600);await playback.release(); // The entire shared surface returns to DOM.// Or: await playback.release({ freeze: true }); // this layer only; GPU stays active.AnimationPlayback.state() exposes logical milliseconds/playing/released and
actual retained source time/state/target detachment. During ownership the
original effect’s target is null and the source is paused; no second animation
or duplicate control tree runs. GPU capture uses neutral transform/opacity,
so source opacity is not baked and then applied a second time. One canonical
transform drives GPU pixels/native geometry. Releasing, disabling, admission
failure, runtime recovery or disposal returns to DOM and restores the same
source/effect at the logical phase. Inline values/priorities/removals are
restored only for properties whose last runtime write is still present.
Later direct or React-authored changes are preserved, including removal or
priority-only changes and unrelated declarations. Live translate/rotate/scale
dependencies, origin and declaration ownership are checked too; they recover
to diagnosed usable DOM without overwriting the external author.
Relinquished typed presentation properties stay preserved across repeated DOM
recovery callbacks. runtimeResources.presentationOverrides counts enrolled
layers with these retained property records; explicit controller/React
transform changes or unenrollment clear them, and final disposal clears all.
Writing the exact same value and priority is not distinguishable from no
change and does not transfer ownership.
Playing sources resume; paused sources remain paused. An ownership handle
cannot be reused after release. Ownership requires no retained cleanup,
outstanding admission readback or initialization/transition.
Supported timing is positive duration <=3,600,000ms, delay 0..3,600,000ms,
integer iterations 1..10000 or Infinity, normal/alternate direction, fill both,
playback rate 1 and document timeline. Two to 32 complete transform+opacity
frames strictly span offsets 0..1. Transform lists are literal
translate(px[, px]) rotate(deg) scale(number[, number]), or neutral none;
positive scales are 1e-4..1e6, translations <=1e6 and authored rotation
<=36000 degrees. Missing translate Y is zero; missing scale Y equals X.
Global/per-keyframe easing supports linear, ease/ease-in/ease-out/ease-in-out
and valid cubic-bezier with X controls 0..1 and control magnitudes <=1e6.
Overshoot extrapolates transforms; only opacity clamps to 0..1. The entire
channel envelope is checked for finite bounds and singular scales before
source mutation. Seeking accepts finite 0..1e12ms.
Reject percentage/calc/var/environment/inherited dependencies, unsupported properties, skew/perspective, additive/iteration composition, steps/springs, reverse/alternate-reverse, other fill modes, iterationStart/endDelay, scroll timelines and overlapping ownership. Existing animation libraries are not automatic adapters. Controller/React transform changes conflict with an owned layer. Source target/effect, frames, timing, composition, rate and externally changed current time are monitored; an observed live conflict uses conservative DOM recovery instead of partial translation.
ownCSSAnimation(id, { rule, keyframes }) accepts one top-level CSSStyleRule
with exact #id selector and one same-sheet CSSKeyframesRule in the same
document. Timing longhands must match the applied cascade, with time units
normalized; inline animation overrides and dependent/overridden raw frames
are rejected. Media/nested rules, arbitrary selector resolution and general
CSS cascade translation are not supported. The caller passes actual trusted
CSSOM references, as /paired.html?example=css does.
In the tested browser, drawable migration can regenerate one exact matching
CSS association and cancel the detached original. The adapter retains that
replacement explicitly, pauses/detaches it, preserves original logical time
and cancels it on restoration. Repeated or incompatible regeneration refuses
GPU. runtimeResources.cssReplacementSources counts this bounded exception.
advance(frames, milliseconds) is a bounded real-RAF deterministic timeline
driver (1..10000 frames, step 0..1000ms), not a GPU readback or performance
benchmark. It advances even paused owned timelines/effects for deterministic
positions; ordinary Play uses elapsed visible RAF time, capped at 100ms per
frame, with no hidden-time catch-up. Reduced motion pauses owners/effects and
refuses deterministic motion; forced colors returns to DOM.
Trusted local module adapter
Section titled “Trusted local module adapter”mountApplicationModule(options, module) mounts an already imported,
trusted local LocalApplicationModule. There is no URL loader, sandbox,
arbitrary unchanged-app compatibility, hydration or animation-library adapter.
const controller = mountApplicationModule(options, { mount({ container, enroll }) { const root = createRoot(container); flushSync(() => root.render(<ExistingLocalApp />)); const unregister = enroll({ id: "existing", element: container.querySelector("#existing")!, layout: {}, transform: {}, preserveStyles: true, }); return () => { unregister(); root.unmount(); }; },});The module owns one root and must return its synchronous unmount callback;
it must clean its own partially created root if mount throws. enroll accepts
original connected direct-root layers, not cloned/reparented controls.
preserveStyles leaves existing declarations intact in DOM; GPU temporarily
transfers only transform/opacity/origin and restores still-owned
values/priorities/removals without overwriting later application writes.
Other existing styles are not normalized. Nonneutral existing presentation
requires supported explicit ownership, not silent duplication. Layout,
stacking, clipping and capture-budget policies still apply.
Decorative ripple
Section titled “Decorative ripple”Surface decorative supplies inert, aria-hidden and pointer-transparent
semantics. Local-module enrollment instead sets decorative: true and must
already provide equivalent native semantics. setRipple(id, { amplitude, seconds, playing? }) accepts amplitude 0..12 CSS px and seconds 0..1e6.
setRipple(id, null) removes the effect. Amplitude zero is identity.
Running time wraps at 1000 seconds; explicit values remain deterministic.
The shader radially displaces texture sampling, attenuating near the decorative layer’s border. It changes no native geometry. Interactive/editable/ focusable/link/dialog/popover descendants are rejected; live loss of decorative eligibility uses diagnosed DOM recovery. Essential controls must be separate unenhanced layers. Effects/resources clear on DOM return/disposal. Reduced motion uses identity and pauses effect time. This is one bounded demonstration, not an arbitrary postprocessing or motion toolkit.
Earlier changes
Section titled “Earlier changes”Changes made before releases were versioned with Changesets; since then, each package’s
CHANGELOG.md records them.
Changes 2026-10-14
Section titled “Changes 2026-10-14”- Per-layer drawing: each layer is now its own blended effect pass (one triangle
covering its canvas-space bounding box) drawn in DOM order after a background
clear, all on the same engine and in one frame submission. The 8-layer cap is
gone: the default layer budget is 32 and
budgets.layersaccepts 1..64 (the pixel budget still applies). Adding, removing or reordering layers reuses the passes of unchanged layers instead of regenerating a shader for the new count, and small layers only shade their own pixels.resources.effects/rendererscount these passes (layers + 1). Compiled CSS program sets are now version 2 with one per-layer program; version 1 modules are refused with a rebuild message. - Ownership while GPU presents:
ownAnimation,ownCSSAnimation,ownCompiledAnimationandcreateMotion(and itsown()) now also work while GPU is active, with no DOM round-trip or readmission. The runtime lifts the layer’s GPU declarations only for the synchronous source verification and writes them back before any rendering opportunity; a refused acquisition restores them atomically. release({ freeze: true }): ends one layer’s ownership and keeps GPU active. The layer keeps its last sampled transform and the source animation is handed back and cancelled (acreateMotionsource is disposed). Supported for WAAPI (ownAnimation) and typed-motion sources; CSS-provenance ownership still usesrelease(), which returns the whole surface to ordinary DOM.- Transform origin: new static per-layer pivot,
origin?: TransformOriginonSurface,useSurfaceRef/enrolloptions andLayerDefinition, plussetOrigin(id, origin).TransformOriginis"top-left"(default),"center"or{ x, y }fractions of the layer’s layout size. See Authoring. - Rotated layers at the frame edge: a rotated or skewed layer may now cross the
output frame’s edge and stay on the GPU. Functional admission rotates its probe
across a visible canvas content edge and checks that the inner half is hit and
the outer half is not (
state().metrics.admission.last.frameBounds:bounded/unbounded/unverifiable). Onlybounded, with the canvas content box equal to the frame, drops the frame clip for such layers; otherwise the old refusal stands. Rotated layers inside a clipping ancestor still use ordinary DOM.
Changes 2026-10-13
Section titled “Changes 2026-10-13”- Policy: boundary-only animation revocation and cached structural checks, as above.
- Reconciliation: React commits coalesce into one microtask; new public
CanvasRuntime.requestReconcile(); one GPU frame per commit instead of N+1. - Uploads: paint events upload only changed or stale layers.
- Transforms: Surface transform/layout updates during a declarative transition or ownership no longer throw; they become the resting/base transform.
- Transitions: pause while the document is hidden and resume with the same progress.
- Presence: starts motion in a layout effect.
animate: accepts{ duration }(ms); a number is still a frame count.- Rendering quality: the compositor filters captures bilinearly in premultiplied space and antialiases layer/clip edges analytically. Pixel-aligned, untransformed layers still sample exact texels (verified identical to ordinary DOM at a 50% alpha probe); rotated or scaled layers no longer alias or show dark edge fringes.
- Stage background: the output canvas is premultiplied and cleared to transparent, so the area no layer covers shows the page’s own background in GPU mode exactly as in DOM mode: a dark page stays dark, a runtime theme change shows on the next frame, and a page with no background shows the browser’s white. There is no stage colour option; set the background on the page (or on the frame/canvas element’s CSS). A layer that is not fully opaque shows that background through it, as in DOM mode, and light a layer adds that has colour but no coverage (the Instrument level’s emitter halo) now adds to the page behind it instead of vanishing on white. The output canvas is no longer opaque, so the compositor blends it with the page (a small cost). Measured on lit panels over a bare page against the same scene over a stage layer of the page colour: no pixel of a panel’s edge band is more than 1 level brighter (Relief, Objects and Instrument, dark and light, DPR 1 and 2), so there is no bright ring at rounded edges. Limit: light multiplies what is on the canvas, so a lamp’s pool past a panel’s edge, and on a panel’s outset shadow, lights nothing on a bare page (the page is not on the canvas, and in DOM mode there is no such light either). To have the pool fall on the page, enrol a plain layer of the page colour beneath the panels, as the emitters pages do.
- DOM-mode rotation: declarative transitions with rotation no longer cancel in ordinary DOM. GPU viewport-clip geometry is only checked while GPU presents.
createCanvasHosts(frame): creates the output canvas, ordinary host and overlay host with the required styles. The output canvas is deliberately notaria-hidden, because GPU mode moves the original controls inside it. The frame may be inside a shadow root: admission asks that shadow root for its native hit proofs (the document retargets every hit inside it to the shadow host), and the!importantpre-scan (animation ownership, declarative transitions in ordinary DOM) reads its sheets as well as the document’s.- Stylesheet scan:
!importantpre-scan skips uninspectable sheets/rules instead of throwing. - Admission: probe sits in the visible part of the output canvas; fully offscreen is still refused.
- Status: new
onStatusChange(status: CanvasStatus);onStatus(JSON string) is optional and kept for compatibility. - Options:
captureHostoptional and deprecated;admissionPolicy: "automatic"is compatibility-only (same as"explicit"). - Unchanged:
playback.release()without options returns the shared surface to DOM (see 2026-10-14 for{ freeze: true }). - CSS compiler: numeric easing comparison, selector-boundary overlap check,
shorthand resets longhands, whole-token CSS-wide keyword guards,
-webkit-animation aliases rejected clearly, inline PostCSS plugins no longer duplicated in Vite. - Worker: load failures terminate cleanly and return a diagnosed DOM state; backing DPR is clamped to its pixel budget instead of revoking GPU; the clock wraps.