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
- Signals —
signal,computed,batch,isSignal,$ - Markup —
cn,bind,Fragment, props, inline styles, lists - Window —
Window,Outlet, and window configuration - Routing —
useRoute,useRouter,Href
Status of every surface
Read from the table in API.md at build time, so this page cannot outrun it.
| Surface | Status | Notes |
|---|---|---|
signal computed batch isSignal | done | src/runtime/signal.ts |
cn(...) conditional classes | done | |
.map(fn, { key }) keyed lists | done | |
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 predicates | done | (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 rows | done | (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() | partial | resolve-refs.ts |
bind:value | done | for 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> | planned | see Form controls below |
<form> — payload by name, onSubmit, validate, onInvalid | done | see Form controls below |
alert() — the platform's modal message box | done | SDL_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() | planned | the 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 scopes | done | (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 |
Show | done | (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 |
source | done | source(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 boundaries | done | (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/** | done | src/compiler/routes.ts, bun run routes |
Href union codegen | done | emitted per window into routes.gen.ts |
useRoute typing + path check | done | src/compiler/route.ts |
useRouter().path | done | the window's route signal, read-only |
bare signal reads — {count * 2}, computed(() => count === 7) | done | source rewrite, src/compiler/reactive-transform.ts; see REACTIVITY.md |
signal.set(value | fn) | done | one method; .value remains for framework code only |
useRouter().matches(path) | done | prefix-aware cell, compiled to a computed in the artifact |
<Window> / <Outlet> | done | src/compiler/window.ts, spliced by bun run window |
one table set per window, inactive routes hidden | done | emitted hidden column, routeChain |
navigate / back | done | (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 bindings | done | (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 table | done | (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 navigation | done | (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 root | done | (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 runtime | done | (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 tags | done | (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 / defineMutation | planned | |
import "./app.css" from a window module | done | module-graph order, src/compiler/css-imports.ts |
| Tailwind as an ordinary project dependency | done | the project's tailwindcss, run in-process, src/compiler/stylesheet.ts |
<style> in an .html document | done | raw text, extracted before the cascade; refused in JSX |
| default stylesheet | planned | |
<Checkbox> <Switch> <Radio> <Toggle> <Tabs> | planned | |
:checked / :disabled variants | done | live, 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 forwarding | done | protocol v13. A radio group is keyed on (form, name), measured |
:indeterminate | planned | same shape and cost, held back until a control can be in that state |
::before / ::after + content | done | generated boxes are real emitted nodes; this is what replaces a UA shadow tree |
::picker(select) | done | protocol 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 ::marker | planned | same 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 flag | done | input[type=checkbox] is how a UA sheet names one control among twenty-two |
<input> <select> <option> <textarea> <label> … as real tags | done | they compile to ordinary boxes; being a tag is not being a widget |
<select> closed, with UA-supplied <button> + <selectedcontent> | done | ua-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-child | done | the 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) | done | protocol 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 before | done | NodeFlags.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 relayout | done | the 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 |
:open | done | one 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 handling | done | half 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 select | done | left: 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 select | done | ArrowDown, 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 all | done | not 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> | done | half 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-select | planned | click-outside and Escape both work; a wheel over the page leaves the picker up |
accent-color caret-color appearance | done | STYLE_FIELDS, checked in conformance and spec-audit |
<Input> | planned | |
onSubmit receives the form's payload | done | collected 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:value | done | the 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, Effect | done | through 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 |
onInvalid | done | issues normalised to { path, message }[] from all three validator shapes |
field="…" — nesting by wrapper | done | the 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 /> | done | a 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 group | done | the 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" | done | plus 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 entry | done | not 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" association | done | ownership 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 rows | done | the 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 template | done | matched 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** — :invalid | done | protocol 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} | done | and 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 it | done | the 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 onInput | planned | |
| a click focusing a bound field | done | editables are INTERACTIVE, so hit_test can return one |
| an empty field is still one line high | done | NodeFlags.EDITABLE, protocol v14. Measured: a field's height is its *font*, not its content |
::placeholder | done | protocol 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 focus | done | a 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 typeable | done | it 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 size | planned | 29 + 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-color | done | a click resolves to the nearest character boundary (measured); the blink is an engine timer, so it survives a busy Bun |
| arrow keys, Home/End | done | consumed by the engine, never forwarded, so a caret move costs one rect and no round trip |
| insert and delete *at* the caret | done | the 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 subset | done | protocol 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 |
currentcolor | done | the 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-offset | planned | a 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+click | done | the 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 all | done | the 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 selection | done | one 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 |
::selection | done | protocol 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 macOS | done | (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 word | planned | a drag after a double click extends by character |
a <label> click focusing a text field | planned | activates forwards to control kinds only, and a text field is not one |
| the caret, selection and open picker as ledger entries | done | half 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 |
opacity | done | a style field, painted as a *layer* so the subtree composites as one |
transform — translate* rotate scale* skew* | done | decomposed into STYLE_FIELDS, protocol v11, composed in paint.rs |
translate / rotate / scale as their own properties | done | they compose in that fixed order regardless of source order, measured |
transform-origin | done | px 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 node | done | the 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-110 | done | reachable only through the resolved style, so hit-testing resolves variants too |
transition-* — property, duration, delay, timing function | done | one interned tweens row and a u16 on the style row, protocol v12 |
@keyframes and the animation shorthand | done | the *same* tween row, with the endpoints coming from a keyframe list instead of two style rows |
per-keyframe animation-timing-function | done | it 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() | done | solved by Newton with a bisection fallback, checked against the measured progress table |
| interrupting a transition | done | a 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-motion | planned | disables 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) | planned | framework wrappers only; planned: global stubs |