Skip to main content

API

Everything an app should need is exported from dziry:

import { signal, computed, cn, Window, Outlet, useRouter } from "dziry";

Deep paths (dziry/runtime/signal.ts and the rest) resolve, but they exist because the emitter writes those specifiers into the generated artifact — not as an invitation. What is re-exported from the barrel is what is intended to keep working.

Two halves that run at different times

This is the distinction worth holding on to, because it explains most of the errors you can hit.

Build time, then gone. Window, Outlet, cn, bind, useRoute and useRouter run inside the compiler and do not exist in the shipped app.

Survives into the process. signal, computed, batch, isSignal and $ are the runtime.

They are exported together because you write them together — a page imports cn and the signal it is conditioned on from the same place. But the split is why a signal created inside a component has nowhere to live, and why an inline style with a non-static value is a build error rather than a silent drop.

Pages

  • Signalssignal, computed, batch, isSignal, $
  • Markupcn, bind, Fragment, props, inline styles, lists
  • WindowWindow, Outlet, and window configuration
  • RoutinguseRoute, useRouter, Href

Status of every surface

Read from the table in API.md at build time, so this page cannot outrun it.

SurfaceStatusNotes
signal computed batch isSignaldonesrc/runtime/signal.ts
cn(...) conditional classesdone
.map(fn, { key }) keyed listsdone
inline style= (string + object)done
per-row conditional classes — cn({ done: t.done })done(2026-08-21, protocol v45) — a recorder-valued cn() entry compiles like a pseudo-state: both cascades are interned behind Predicate.ROW, the element gets a control row to carry the bit, and updateLists writes ControlFlags.ROW per replica from the row's data. One data-driven class per element (one bit). row-state.test.tsx
text runs follow their element's predicatesdone(2026-08-21) — a run's variant rows are the *projection* of its element's onto inherited text fields (textRunVariants in compile.ts keeps only the bits that move the projection, so a ring or hover background costs a run nothing), and the engine resolves a TEXT node's predicates against its parent — the redirect GENERATED boxes already had, two hops for a pseudo-element's own text. So .done { color; text-decoration }, .field:invalid { color } and a:hover { color } reach the text the element shows. Scope: an element's predicates reach its own runs; a deeper descendant binds the class itself. Fixed four goldens where a picker/listbox option's text had not been taking :focus/:checked colour
checked={t.done} in list rowsdone(2026-08-21, v45) — ControlFlags.DATA_CHECKED marks the row's checkedness as data-owned; rescan re-reads CHECKED from the table for exactly those rows, updateLists writes it from the recorded path, and a user's click still flips optimistically until the data catches up. :checked styling rides the existing predicate. A *signal*-valued checked outside a row is still refused with the dropped-signal warning
ref()partialresolve-refs.ts
bind:valuedonefor text-entry fields, and it is two-way — typing writes the signal, and a signal write repaints the field (so a loader can seed an edit form). The display half is a text binding jsx() inserts as the field's child; a launch-ordering gap that swallowed writes from an initial route's sync loader was fixed 2026-08-21 and is pinned by a test. This row previously said "append + backspace" — caret, selection, word-select and Home/End all landed with A5. Still missing: clipboard and IME (A5's remainder)
form controls — <Checkbox> <Switch> <Radio> <Toggle> <Tabs> <Input>plannedsee Form controls below
<form> — payload by name, onSubmit, validate, onInvaliddonesee Form controls below
alert() — the platform's modal message boxdoneSDL_ShowSimpleMessageBox behind the FFI, so it is a Win32 task dialog, an NSAlert or the GTK box and not something dziry draws. Nothing was vendored: SDL3 is already linked. Shown on the engine thread, because SDL requires the thread that initialised video, so app code posts a message; headless is a no-op so screenshots and goldens are unaffected
confirm() / prompt()plannedthe same call with an answer, which needs a reply message rather than a return value: the thread that would answer is the one the dialog is blocking
effect untrack peek cleanup, createScope disposal scopesdone(2026-08-20) — src/runtime/signal.ts: effect(fn) (cleanup via return, dispose via handle, batched), untrack(fn), sig.peek(), createScope() with transitive disposal. Includes the dep-set fix: re-capturing subscribers leave sets they no longer read
Showdone(2026-08-21) — <Show when={…} fallback={…}> (src/compiler/show.ts): both trees compile in as co-resident siblings — the route/<Suspense> mechanism — and the worker flips hidden bytes on the condition's truthiness (applyShows, src/host/window-state.ts). when takes any cell: a module signal/computed by identity, or an inline expression (when={count > 5}) the transform wraps and the artifact re-creates as computed(() => …). A constant when is resolved at build time — the winning tree is spliced, the loser never becomes nodes. fallback is optional (a closed Show is deliberately nothing; a pending Suspense is not, which is why its fallback stays required). The compiled hidden column ships the condition's build-time value, so the first frame needs no write; the worker settles once at launch for loader-seeded cells. One walk splices both marker kinds, so a bare <Show> at a <Suspense>'s top level (and every other bare cross-nesting) is refused — wrap it in an element
sourcedonesource(subscribe, initial): a signal fed from outside. The subscribe is handed set, and what it returns decides the shape — an unsubscribe (callback, no effect), or an Effect Stream run with Stream.runForEach after a lazy import. src/runtime/source.ts
resource / <Suspense> / error boundariesdone(2026-08-21) — resource(fetcher, initial) (src/runtime/resource.ts): the data is the signal (one export, one import name), with status/error signals and refetch() riding on it; registered at import, started by the worker at launch, so the compiler's import of app modules never fetches. Status walks pending → ready | error; refetch() sets stale, never pending, so revalidation cannot flash a fallback. <Suspense fallback={…}> (src/compiler/suspense.ts) compiles both trees as co-resident siblings — the route mechanism — and the worker flips hidden bytes when a watched resource's status crosses pending (applyBoundaries, src/host/window-state.ts). Watched resources are collected from the bindings under the content; reads hidden inside a computed need on={[stats]} (the pending bit does not propagate through derived cells). An empty boundary is a compile error: *nothing under this boundary can pend*. Error boundaries are the route object's errorComponent (shipped 2026-08-18); a resource error keeps content up and lands in stats.error/stats.status for the app to render
token() (context)planned
onFrame(dt)planned
<Overlay>planned
route table from windows/*/pages/**donesrc/compiler/routes.ts, bun run routes
Href union codegendoneemitted per window into routes.gen.ts
useRoute typing + path checkdonesrc/compiler/route.ts
useRouter().pathdonethe window's route signal, read-only
bare signal reads — {count * 2}, computed(() => count === 7)donesource rewrite, src/compiler/reactive-transform.ts; see REACTIVITY.md
signal.set(value | fn)doneone method; .value remains for framework code only
useRouter().matches(path)doneprefix-aware cell, compiled to a computed in the artifact
<Window> / <Outlet>donesrc/compiler/window.ts, spliced by bun run window
one table set per window, inactive routes hiddendoneemitted hidden column, routeChain
navigate / backdone(2026-08-20) — src/runtime/navigate.ts, exported from dziry. The host installs the window's route signal at launch; navigate(path) writes it (same-path early-out), back() reads the one-entry history — one entry by decision, so a second back() oscillates. navigate("…") literals in captured handler sources are checked against the route table like hrefs (deadNavigations in build.ts); module-level handler bodies cross the boundary as names and are the Href type's to check. Before the window is up, navigate() warns and does nothing — modules are also imported by the compiler
useRoute params as bindingsdone(2026-08-18) — recorders (route-args.ts) reach the emitter through the param sentinel; $id binds as a signal the router writes on navigation. The demo's products/$id.tsx renders {id} live
href checked against the route tabledone(2026-08-20) — matchHref in src/compiler/routes.ts (first hit over the match-ordered table, so static-beats-param is the existing sort), auditLinks in src/compiler/build.ts. A dead link fails the build naming the window's routes; a checked link's click is synthesized as a write to the window's route signal, and an authored onClick wins over synthesis. Refused by name rather than half-working: interpolated hrefs, links inside list templates without an onClick, and external URLs
route loader — sync fn | async fn | Effect; exits drive navigationdone(2026-08-18) — defineRoute() route objects: loader as sync fn | async fn | Effect, Redirect/Cancel exits navigate, Effect recognised by its registered symbol and imported lazily. Failure renders the route's errorComponent and in-flight its loadingComponent — the design's failure.tsx tag-named exports did not ship; the route object carries the views instead. Demo: products/$id.tsx
<Window layer={…}> — Effect Layer as the window's DI rootdone(2026-08-15) — src/compiler/window.ts captures it, src/compiler/build.ts resolves it to an export name, src/runtime/effects.ts builds the ManagedRuntime at launch and disposes it on quit so Layer.scoped finalizers run. Launch-failure *view* still rides M8; today a failed layer prints at launch
handlers may return an Effect — run on the window's runtimedone(2026-08-15) — every dispatch path (click/change/focus/blur, list items, form submit/invalid) hands the return to runDispatched; failures print the full Cause, interruption is silent. effect recognised structurally and imported lazily; apps without it load none of it
Redirect / Cancel navigation tagsdone(2026-08-15) — exported from dziry, dependency-free classes failable from Effects and throwable from plain functions; the router that *interprets* them rides M7/M8 with loader
defineQuery / defineMutationplanned
import "./app.css" from a window moduledonemodule-graph order, src/compiler/css-imports.ts
Tailwind as an ordinary project dependencydonethe project's tailwindcss, run in-process, src/compiler/stylesheet.ts
<style> in an .html documentdoneraw text, extracted before the cascade; refused in JSX
default stylesheetplanned
<Checkbox> <Switch> <Radio> <Toggle> <Tabs>planned
:checked / :disabled variantsdonelive, and a click changes them. controls.rs owns the state; nodes.activates + the controls table are the compile-time half
checkbox and radio activation, radio groups, label forwardingdoneprotocol v13. A radio group is keyed on (form, name), measured
:indeterminateplannedsame shape and cost, held back until a control can be in that state
::before / ::after + contentdonegenerated boxes are real emitted nodes; this is what replaces a UA shadow tree
::picker(select)doneprotocol v18, and the first *functional* pseudo-element. ::picker bare is refused: the spec defines the argument so a future control can name a picker of its own, and a shorthand no browser has is a divergence someone copies out of dziry
::picker-icon ::checkmark ::markerplannedsame machinery as ::before, refused by name until the parts they draw exist. The demo's arrow is select button::after today, which is the same node either way
attribute selectors — [a] = ~= |= ^= $= *=, i flagdoneinput[type=checkbox] is how a UA sheet names one control among twenty-two
<input> <select> <option> <textarea> <label> … as real tagsdonethey compile to ordinary boxes; being a tag is not being a widget
<select> closed, with UA-supplied <button> + <selectedcontent>doneua-structure.ts; the parts a browser builds in a shadow tree, built as nodes. The <selectedcontent>'s text follows the committed option, through a per-node redirect rather than a string write: Bun owns the tables, so the engine repoints *which* node's slot the run reads
select > option, option:first-childdonethe picker is spliced in at the *node* level, so the options' selector path still ends at the select. A browser's picker is a pseudo-element the light-DOM options render into, not a wrapper they move under, and this keeps that true. option:first-child matches the first option now: positionOf used to count the UA-supplied <button>, which shifted every option by one
<select> picker (open state)doneprotocol v18. Opens on the press (measured; the opposite of a checkbox), commits on the release or Enter, dismisses on Escape or an outside press — and that press still activates what it hit, which is a second measured rule and not the same as "the overlay consumes its own presses"
the overlay layer — paint after, hit-test beforedoneNodeFlags.OVERLAY, and it is a flag rather than a second tree because the subtree is already in the right place: only its turn in the walk moves. Both halves are load-bearing and for different reasons — in tree order a picker draws *under* what follows its select, and hit_test prunes on the parent's box, which a picker hangs below
opening a picker costs no relayoutdonethe box is position: absolute and laid out whether or not it shows, so showing it is a pure paint decision. The same split ::placeholder uses. Committing *does* relayout, once, because the closed button's width comes from the chosen label
:opendoneone integer for the document, because only one popover can be open at a time (measured). Reaches select::picker(select) through GENERATED, so it means "the picker of an open select"
anchor positioning, collision handlingdonehalf done — the engine offsets a picker onto its select's bottom edge from the two rects layout produced, because the spec's top: anchor(bottom) has no dziry spelling (top: 100% would be it, and percentage lengths are refused). It does not flip or shift near a window edge; that is B2's @floating-ui/core adapter
a picker as wide as its selectdoneleft: 0; right: 0 in the UA sheet, which stretches an absolute box to its containing block. That is the spec's min-inline-size: anchor-size(self-inline) reached with two plain lengths, and unlike a width in a theme it cannot drift out of step. It is a *fixed* size rather than a minimum: an option longer than the select will not widen the picker, which needs min-inline-size with a value dziry cannot yet express
keyboard: which keys open a closed selectdoneArrowDown, ArrowUp, Space, F4 and Alt+ArrowDown, all measured 2026-08-06. Enter does not open one — measured, and it was asserted to: Enter is the *commit* key, and one that also opened would make Down-then-Enter ambiguous. The belief comes from a legacy select in a <form>, where Enter submits, and from macOS
keyboard: reaching a select at alldonenot done, and this is the gap that matters — there is no Tab order, so a <select> cannot be focused without a pointer. Every keyboard behaviour above is therefore only available to someone who can already use a mouse, which is not keyboard accessible however correct the arrow handling is
<optgroup>donehalf done — its options are the select's own: they arrow, highlight and commit like any other, and the group is descended into rather than scanned past. The label attribute is accepted, selectable by [label], and not rendered — that wants a generated box whose text comes from an attribute, which is exactly what ::placeholder already does
scroll-outside dismissal, type-to-selectplannedclick-outside and Escape both work; a wheel over the page leaves the picker up
accent-color caret-color appearancedoneSTYLE_FIELDS, checked in conformance and spec-audit
<Input>planned
onSubmit receives the form's payloaddonecollected by name from the form's subtree, typed by control kind, with the browser's inclusion rules (measured, guards/probes/form-data.html). src/compiler/fields.ts decides the shape, src/runtime/forms.ts reads the cells
a named field with no bind:valuedonethe compiler declares its cell in the artifact, so a browser-shaped form needs no state module. Typing reaches it through the same editables table a bound field uses
validate={schema} — Zod, Valibot, ArkType, Effectdonethrough Standard Schema's ~standard, plus one lazy-import branch for a raw Effect schema, which carries no ~standard of its own (measured, effect 3.22). dziry depends on none of them
onInvaliddoneissues normalised to { path, message }[] from all three validator shapes
field="…" — nesting by wrapperdonethe wrapper chain is the path, so { position: { x, y } } needs no bracket syntax. No browser nests anything (measured); a path claimed as both a value and a group is a build error
errorClassName + <span error />donea class on the wrapper, compiled to style-table patches, so the error story is CSS. Independent per wrapper even when the class string is shared
<span error="city" /> — a named message inside a groupdonethe name is relative to the wrapper, as name is, so a group stays movable. Each marker shows the first issue under its own path that no *more specific* marker would show, which divides a group's complaints between its leaves and its own line with nothing said twice. The class stays singular: "something here is wrong" is one fact however many messages describe it. A name no field produces is a build warning, because a marker that can never fill looks exactly like a field that is never wrong
validateOn="submit|change|blur"doneplus two rules that are behaviour rather than knobs: re-validate on change after a failed submit, and no error before a field has moved off its compiled value
the submitter's own name/value entrydonenot done — measured (a named <button type=submit> contributes only when it is the button that submitted) and deliberately left out: it is the one entry that is not a property of the markup, and a two-button form in dziry would use two onClicks
form="id" associationdoneownership rather than ancestry, resolved once and read by all three questions a form asks: its payload, its default button, and its blocking-field count. Measured (guards/probes/form-owner.html), including that a form= naming no form orphans the control rather than falling back to its ancestor
a field wrapper holding a map() — repeating rowsdonethe wrapper's value is the list's array, so the payload gains Job[] with one entry per live row. The only field whose state the compiler does not declare: an arena of interchangeable replicas has nothing stable to hang a per-row cell on, and the array has a keyed entry per row already. bind:value={job.title} writes back into it
a row's own error message — <span error /> in the templatedonematched by *data position*, so a reorder cannot carry a message to the wrong row. The section's own message then shows only issues at its own path, while its errorClassName still goes on for anything under it: the class and the message part company exactly here
a row's own error **styling** — :invaliddoneprotocol v39. A predicate rather than a class, because a class *is* a style row and replicas share one: :invalid is a control flag Bun writes after validation and the engine re-reads on rescan, resolved per node, so one row can be red and its neighbour not. Every text-entry <input> now carries a control row so the flag has somewhere to live
a submit button switched off by disabled={signal}doneand the Enter path had to be told: a *literal* disabled makes the compiler emit button: -1, which blocks outright (measured), but a signal cannot be seen at build time. submitForm now reads the live flag, so a greyed-out button is unsubmittable by press *and* by Enter. bind:checked would remove the duplicate signal a gated button needs today
an alert() raised from a handler shows the frame that caused itdonethe request is queued until the app thread's next commit and the engine paints once more before blocking. It is raised inside the submit batch(), so nothing had reacted to the error cells yet: the box went up over the pre-submit picture, listing complaints that were invisible behind it
onChange vs onInputplanned
a click focusing a bound fielddoneeditables are INTERACTIVE, so hit_test can return one
an empty field is still one line highdoneNodeFlags.EDITABLE, protocol v14. Measured: a field's height is its *font*, not its content
::placeholderdoneprotocol v15. An ordinary generated box, like ::before, with two differences: its text comes from the attribute rather than content, and paint draws it only while the field is empty
a disabled field refuses focusdonea disabled form control now gets a controls row, so the engine can see it. A press on one produces no mousedown, mouseup or click at all, as measured
<input type=number|range> is typeabledoneit was in the payload's kind table before it had an editor, so it compiled to a box with no line height: four pixels of border, which is what the forms demo drew where its age field should have been. A browser routes both to the same text editor and adds chrome dziry has no equivalent for (a spinner, a slider track), so being typeable is the part that transfers. The implicit-submission blocking set stays the six text keywords it was measured over — widening it here would have changed a measured rule as a side effect of a layout fix, and whether a number blocks is unmeasured
a field's **width** from sizeplanned29 + 7 × size px is measured (BROWSER-FACTS.md), and unimplemented: size="20" does nothing, so an <input> with no width class fills its container instead of being 169px
caret — position, blink, caret-colordonea click resolves to the nearest character boundary (measured); the blink is an engine timer, so it survives a busy Bun
arrow keys, Home/Enddoneconsumed by the engine, never forwarded, so a caret move costs one rect and no round trip
insert and delete *at* the caretdonethe engine reports the index beside the text; typeInto splices there, clamped, by characters rather than UTF-16 units. Backspace erases behind and moves the caret; Delete erases in front and does not
box-shadow — the ring subsetdoneprotocol v16. No offset and no blur, a solid spread, stored as three concentric bands, which is exactly what ring-*, inset-ring-* and ring-offset-* compile to (measured, BROWSER-FACTS.md). shadow-md warns and draws nothing rather than being approximated
currentcolordonethe element's computed color, substituted textually before the expander. Not dynamic: the cascade already resolves color per node. Needed because bare ring-2 reaches it through a var() fallback
outline / outline-offsetplanneda ring is what Tailwind reaches for and what landed; outline's own fields are still absent, so outline-* utilities warn
selection — drag, Shift+Arrow, Shift+clickdonethe engine holds (anchor, focus), not an ordered range, because that is the only shape a Shift reversal survives: from a caret at 5, Shift+Left walks 5..6, 5..5, 4..5 backward with the anchor still at 5 (measured)
double click for a word, triple click / Ctrl+A for alldonethe segment at the *nearest boundary* plus its trailing whitespace run, which is one rule over thirteen measured rows. A double click does not use the character under the pointer: at 9.55 in quick-brown it selects brown
editing over a selectiondoneone splice replaces the range. Backspace and Delete are *identical* once a range is live, so the direction only widens a collapsed caret; insertion leaves the caret after what it inserted
::selectiondoneprotocol v17. Two inherited colours on the originating element's row, not a node: a selection is a range inside a box rather than a box. The default is a stated convention in dziry's UA sheet, because Chromium does not expose its own highlight colour to script
clipboard — Ctrl+C/X/V, ⌘ on macOSdone(2026-08-21) — the *decision* lives in the engine beside Ctrl+A, because the forwarded KEY_DOWN deliberately carries no modifier mask. Copy never crosses the boundary; a cut arrives as the Backspace-over-a-range it is; a paste is a new PASTE event whose text waits in the engine (Event.text is 32 bytes) and is fetched beside the drain. Line breaks become spaces, one per break — measured, BROWSER-FACTS.md "Newlines in a single-line input". Headless engines get a process-local fallback clipboard, which is what makes tests/clipboard.rs possible
IME, double-click-then-drag by wordplanneda drag after a double click extends by character
a <label> click focusing a text fieldplannedactivates forwards to control kinds only, and a text field is not one
the caret, selection and open picker as ledger entriesdonehalf done — the state is built and each argument is in its own module header (caret.rs, select.rs); the NOTES.md entries ROADMAP A5 and B1 ask for are still owed, and the picker has now made it two. All of them fail the compile-time gate at question 3: nothing declares them and none is bounded, so they are engine-owned interaction state beside hovered and focused. The picker's is the narrowest of the three — one integer for the whole document, because only one can be open
opacitydonea style field, painted as a *layer* so the subtree composites as one
transformtranslate* rotate scale* skew*donedecomposed into STYLE_FIELDS, protocol v11, composed in paint.rs
translate / rotate / scale as their own propertiesdonethey compose in that fixed order regardless of source order, measured
transform-origindonepx and percentage per axis; the 50% 50% default is a percentage, so the *engine* resolves it against the laid-out box
hit-testing a transformed nodedonethe pointer is mapped by the inverse on the way down the tree, so a parent's transform moves its children's hit areas as it moves their pixels
a transform in a variant — hover:scale-110donereachable only through the resolved style, so hit-testing resolves variants too
transition-* — property, duration, delay, timing functiondoneone interned tweens row and a u16 on the style row, protocol v12
@keyframes and the animation shorthanddonethe *same* tween row, with the endpoints coming from a keyframe list instead of two style rows
per-keyframe animation-timing-functiondoneit governs the segment *leaving* the keyframe, measured; a column on the keyframe row, which is what makes Tailwind's bounce expressible
easing — keywords, cubic-bezier(), steps()donesolved by Newton with a bisection fallback, checked against the measured progress table
interrupting a transitiondonea reversal is the same pair of rows *rewound*, so it takes the distance still to travel and starts from the value already reached, measured
prefers-reduced-motionplanneddisables animation rather than slowing it, and it wants a global predicate bit rather than a media *threshold*, which is what the media table currently holds
persistent handles (timer, socket, watcher)planned: active-handle diff per import
writes (fs, network, db)plannedframework wrappers only; planned: global stubs