Skip to content

Latest commit

 

History

History
2286 lines (2012 loc) · 127 KB

web-api.md

File metadata and controls

2286 lines (2012 loc) · 127 KB

VelarScript Web API

Status: active clean-break design Runtime: existing browser JavaScript engine; no virtual DOM

VelarScript Web applications install and declare @velarscript/web. The extension owns JSX, components, reactivity, lifecycle, controlled Look values, Web types, editor contributions, and explicit Web modules instead of implicit browser globals. The compiler reports VEL3008 for direct source-level use of console, document, window, navigator, location, history, fetch, JSON, Math, or Date and points to the official module or an explicit JavaScript boundary. This document defines the current API; removed experiments do not remain as compatibility aliases.

mount, tick, viewport, scheme, and motion are reserved Web bindings. mount and tick are real runtime entry points; viewport, scheme, and motion name the Look media subjects, so a local binding can never shadow a media condition. A Web module refuses all five as binding names with VEL3007, which is why a scroll container is named scroller rather than viewport. These five words are the whole difference between the names a Core module accepts and the names a Web module accepts; every other word the extension adds is a contextual keyword that remains available as an ordinary name.

One call convention

Every fixed Web API uses the same call rule as an ordinary VelarScript function: arguments may be positional or explicitly named with name=value. Names are part of the checked API contract, optional values may be omitted, and supplied expressions still run from left to right as written even when their names are out of declaration order.

const request = http.get(options={timeout: 5000}, url="/api/users")
const current = storage.get(target=User, key="current")
storage.set(value=current, key="current")
scrollTo(behavior="smooth", y=400, x=0)

Use positional arguments when their meaning is already obvious and named arguments when they remove ambiguity. The compiler rejects unknown, repeated, or missing names instead of guessing. Callback parameter names remain local to the callback; only the function that receives the callback defines the public call contract.

Component constructor values

The Web extension exposes Component as the structural type of a component constructor. Use bare Component when a host will pass no application props, or write a named JSX-prop contract such as Component<(item: Item, compact?: bool) -> WebNode>. Optional markers govern omission: compact?: bool may be absent, whereas compact: bool? is required and may contain null. All components also accept the implicit optional host props class and look.

A compatible constructor accepts every prop the contract permits and adds no new required prop; additional optional props are allowed. Render a received constructor with a PascalCase JSX identifier (<View item={item} />). It is not an ordinary callable and is distinct from an already-rendered WebNode. The selected constructor keeps its instance while its identity is unchanged, including across live prop updates. Replacing it with a different constructor runs cleanup for the old instance and mounts the new one; host class and look forwarding follows the new root.

Application prop data is mutable by default. A field assignment or mutating collection method through a prop uses the same deep-reactive publication path as the source state, so sibling and parent consumers update without replacing the object. Write readonly T in the component declaration when that component should promise not to write the data. This is the existing transitive Core read-only view: it is an author-selected contract with no runtime copy or freeze, and it does not change readonly semantics outside props. The live prop binding itself cannot be reassigned; mutate its declared data, or ask the parent to supply a new prop value.

Component Handles and ref

A component may expose a narrow imperative Handle without exposing its internal scope, state cells, or component runtime object. The declaration names one concrete record contract with exposes T and provides exactly one matching record value with expose expression:

type EditorHandle:
    focus: () -> null
    reset: () -> null
    value: () -> string

component Editor(initial: string = "draft") exposes EditorHandle:
    state text = initial

    def focusEditor(): pass

    def reset(): text = initial

    def value() -> string: return text

    expose {focus: focusEditor, reset, value}
    return <input host bind:value={text} />

component Page:
    let editor: EditorHandle? = null

    @mounted:
        if editor != null: editor.focus()

    return <Editor ref={editor} look:borderWidth={1px} />

expose is a position-independent top-level component declaration: it may be written before or after the single top-level return, but not inside a branch, function, lifecycle block, or other nested scope. Convention keeps return last and places expose immediately before it. Regardless of source order, the root is constructed before the Handle value is evaluated, so exposed element refs already refer to their constructed native elements.

Component ref is a compiler-owned JSX directive rather than a prop. It requires a mutable optional let binding because cleanup restores null. Components without exposes reject ref, and component props cannot be named ref. The Handle becomes available after the child root has been constructed and before the parent's @mounted block runs.

The runtime creates one shallow-frozen Handle per component instance. Callable fields are guarded capabilities: after the instance is destroyed, a saved old Handle throws instead of reaching destroyed state or DOM. Conditional removal, keyed removal, and dynamic constructor replacement all clear the active ref; identity-checked cleanup prevents an old instance from clearing a newer Handle stored in the same binding.

Use Handle functions for imperative commands such as focus, reset, open, close, and scrollTo. Prefer getter functions over publishing mutable state objects, and use callback or Component props when the parent is configuring or replacing behavior. Styling remains declarative: every component accepts class, look, and look:* on its stable host. A component that wants to style internal parts should expose named Look props instead of leaking DOM through its Handle.

The optional second argument of a structural component contract carries the Handle type across locals, imports, exports, and dynamic component positions:

type EditorHandle:
    reset: () -> null

type EditorView = Component<(initial?: string) -> WebNode, EditorHandle>

Handle compatibility is covariant: a constructor used as Component<Props, Handle> must expose a value assignable to Handle. Omitting the second type argument means callers cannot request a ref through that structural contract.

Hosts, fragments, and JSX directives

A component invocation may attach class, class:*, look, look:*, and style:* without knowing the component's internals. Those land on the component's host element. When the component returns a single native element, that element is the host; when it returns another component, the forwarding continues into that component's host. When it returns a fragment — <>...</>, several roots with no wrapper element — there is nothing to infer from, so exactly one native element in the returned tree carries the valueless host marker:

component Field(label: string, value: string):
    return <>
        <label>{label}</label>
        <input host value={value} />
    </>

component Form:
    return <Field label="Name" value="Ada" class="field" look:marginBlock={8px} />

Two markers in one component, a marker with a value, and a multi-root component with no marker are each a compile error, and the runtime raises the same requirement for a dynamically constructed root. host also belongs on a single-root component whose root is not the element that should receive host styling — mark the intended element and the root stops being the default.

The directives a native element accepts are on:event, bind:value, bind:checked, bind:group, class, class:name, look, look:property, style:property, ref, key, and unsafe:html.

A native element also reserves every attribute name beginning with on, other than the on: directive itself. The handler spellings among them — onclick, onerror, onload, and the rest — are content attributes the browser compiles as script, so any string routed there is executable code in the application's origin, which VelarScript reserves for unsafe:html. An HTML attribute name is matched case-insensitively, so onclick, onClick, and ONCLICK are one attribute and all three are refused. The prefix is closed by name rather than by a roster of event names, so the next handler attribute the platform adds is closed in advance; the same rule also refuses an ordinary word such as onward that happens to start with the two letters. Events are written with the on: directive: on:click={handler}.

class:name={condition} toggles one class name from a bool. It composes with class={...}: the bound list and the toggles are independent contributions to the element's class attribute, so a static class, a reactive class list, and several toggles coexist. The value must be bool — an optional or a truthy value is rejected.

component Tab(label: string, selected: bool, disabled: bool):
    return <button class="tab" class:selected={selected} class:muted={disabled} type="button">{label}</button>

An event directive may carry modifiers, appended with dots. There are exactly five, an unknown one is a compile error, and none may repeat:

Modifier Effect
prevent Calls preventDefault() before the handler runs.
stop Calls stopPropagation() before the handler runs.
self Ignores the event unless this element is the event's target.
capture Registers the listener for the capture phase.
once Removes the listener after one dispatch.
component Dialog(close: () -> null, submit: () -> null):
    return <div class="backdrop" role="presentation" on:click.self={close}>
        <form on:submit.prevent.stop={submit}>
            <button type="submit">Save</button>
        </form>
    </div>

self is the supported way to ask the question the absent event.target would have answered — "was this the element itself, or a descendant?" — which is what a backdrop-dismiss handler needs.

A modifier prevents every dispatch, which is what a form submit wants and almost never what a keystroke wants: on:keydown.prevent would swallow typing along with the key it was aimed at. Prevention that depends on which event arrived is written in the handler body instead, with event.preventDefault() — a typed field on every event object. That division is the whole design: the modifier is the unconditional case, the call is the conditional one, and neither replaces the other, which is why there is no per-dispatch modifier to look for.

def sendOnEnter(event: KeyboardEvent):
    // `isComposing` is true while an input method is choosing a candidate, and
    // Enter is how a candidate is chosen. Sending without asking sends half a
    // word — the normal case for a CJK typist, not an edge case.
    if event.key == "Enter" and not event.shiftKey and not event.isComposing:
        event.preventDefault()
        send()

Attribute expressions use one presence rule: false and null remove the attribute, true writes it with an empty value, and a string writes that exact text. Use str(flag) when a bool is the source of a text-valued attribute such as an ARIA state. The compiler offers the equivalent rewrite from flag ? "true" : "false" to str(flag); it does not apply that rewrite to an HTML boolean attribute, where text would keep the attribute present.

component SaveButton(saving: bool, pressed: bool):
    return <button type="button" disabled={saving} aria-busy={str(saving)} aria-pressed={str(pressed)}>Save</button>

While saving is false the button carries aria-busy="false" and no disabled attribute; while it is true it carries aria-busy="true" and disabled="".

A key drives identity-preserving reuse, and reuse asks two questions: the key must match, and the item that key names must still be the same value. Rebuilding the rows — items = items.map(item => {...item, done: true}), the React habit — replaces every value, so every row is destroyed and built again, an input being typed into loses focus, and an open composition ends. Change the field in place instead, items[index].done = true; the key still names the same value and the row survives. The rebuild is reported rather than left silent: advisory A4 names the rows a keyed position renders when something rebuilds them, and gives the alternative that keeps them.

A4 reads the rebuild in whichever of its two spellings the module uses. The assigned one is items = items.map(item => {...item, done: true}) above, and its alternative is the field write next to it. The derived one is a computed that builds its rows — either computed rows = source.map(item => {…}) or a computed over a def that fills a fresh list with record literals — and it rebuilds every row on every recompute, which reaches the keyed position the same way. A derived value owns no row it could write, so there the alternatives are to render the source rows and change the field on those, or to carry the source records through rather than constructing new ones. The advisory never fails a build and never moves an emitted byte; // velar-allow A4: <reason> suppresses either spelling where building the rows is the only one available, which a readonly list or a set of rows arriving whole from one response makes it.

The same two questions decide whether a child component keeps its instance, and the answer is worth stating positively because a child that is rebuilt every frame loses whatever it was accumulating. Two positions preserve it. A component element written as an ordinary child — <div><Preview text={draft} /></div> — is constructed once for the life of that position: its props are live cells, so a prop changing updates the instance instead of replacing it, and its state, @mounted work, focus and IME composition all survive. A keyed row preserves it on the terms above: same key, same value.

A component element inside an interpolation is a region rather than a child, and a region is rebuilt when a read that decides its shape changes — the condition of a ternary, and so the branch taken. A read inside a prop expression is not one of those: it keeps the instance alive and updates it live, exactly as a child position does.

state draft = "d1"
state tag = "t1"

component Preview(text: string):
    return <b>{text}</b>

component Empty:
    return <i>nothing yet</i>

component Board:
    return <div>
        {<Preview text={draft} />}
        {tag == "" ? <Empty /> : <Preview text={f"tagged {tag}"} />}
    </div>

draft is read by a prop expression, so the first region's instance is built once and its text is updated in place when draft changes. tag is read by the second region's condition, which is what decides that region's shape, so writing tag rebuilds it — @cleanup on the old instance, @mounted on a new one — even though the branch taken did not change.

So an instance that is meant to live across updates does not have to be moved out of the interpolation: only a shape read replaces it. A branch that really does flip replaces the instance, which is the point of the branch.

A component element is built by the position that shows it, and that decides what it evaluates to. In a child position it is a rendered node, which is every position inside markup: <div><Card /></div> and an interpolated {ready ? <Card /> : null} alike, wherever that markup is written. Standing on its own it is the component instance instead — const root = <App /> is the value mount takes, and reading root.node is how a host reaches its DOM.

The two are not interchangeable, and one helper shape confuses them: a def that answers WebNode and answers it with a component element standing alone, return <Card ... /> or labels.map(label => <Card label={label} />), hands its caller an instance where a node is expected, and rendering it fails. That is VEL5075, refused where the helper is written rather than left to fail at render. A def that answers markup is otherwise the ordinary way to dispatch over a closed vocabulary: it may build native elements freely, it may hold component elements in child positions of the markup it returns, and a helper nested inside a component body is unrestricted, because the enclosing component is the position that owns what it builds.

JSX text is normalized before it becomes a text node. Every whitespace run inside a text child collapses to one space; whitespace that exists only because the source wrapped is then dropped, so a text run beginning at a line break loses its leading whitespace and one ending at a line break loses its trailing whitespace. The consequence is the useful one: indentation never adds spaces to the rendered output, while a deliberate space between two inline elements on the same line survives. A text child that normalizes to nothing produces no node, and text that must be preserved exactly belongs in an interpolated string.

Because that region is text, it has no comment form, and every comment spelling an author reaches for there is refused as VEL5002 rather than rendered: <!-- ... --> between children, {/* ... */} in an interpolation, and a // or /* ... */ opener standing at the start of its own physical line inside a text run. The last is scoped to that one shape on purpose, because everything else that reads as a comment is ordinary content: https://host in a sentence, and an inline a // b, are text and stay text. Write the note above the markup where // is a comment, or, for a line of literal text that begins with an opener, interpolate it — {"// not a comment"}.

What an interpolation leaves in the document follows from what its type can be. A string or a number renders as exactly one text node — never nothing, never markup — so that is all it is: the node is created once and its text is rewritten in place when the value changes, and nothing brackets it. Every other type can render nothing, or markup, or several nodes at different times, so its position is bracketed by a velar:start/velar:end comment pair marking the region the renderer owns and rebuilds; a keyed list carries velar:keyed-start/velar:keyed-end for the same reason. A bool is on that side of the line because it renders nothing: {saving} puts no text in the document, in either state. Those comments are the renderer's bookkeeping — they never affect rendering, and they are what appears between the two spellings when a document is inspected.

velar/look

Visual unit suffixes are language syntax and require no import. px, rem, em, vw, vh, vmin, and vmax are Length values; % is Percentage; fr is TrackFraction; ms and s are Duration; deg and turn are Angle. They can be bound, exported, imported, and calculated outside a look: block.

The builders are named imports from velar/look, so the import list at the top of a file names the visual vocabulary that file uses. The kinds they produce — Look, Length, Percentage, LengthPercentage, TrackFraction, Color, Duration, Angle, Border, Shadow, Filter, Image, Track, TrackList, Transition, Spacing, Keyframes, and Animation — are type names a Web module writes without importing anything, in an annotation and in an is check alike. velar/look also publishes each of them as a Type object, under the same named-import spelling as the builders, for the value position that needs one:

import {border, clamp, rgb, spacing} from "velar/look"

export const compact = 40rem
const accent = rgb(45, 79, 190)
const fluidWidth: LengthPercentage = 100% - 32px

export const panelLook = look:
    width = fluidWidth
    padding = spacing(24px, 16px)
    border = border(1px, accent)
    fontSize = clamp(16px, 3vw, 24px)

    if viewport.width <= compact:
        padding = 12px

The available velar/look builders are token, color, rgb, rgba, hsl, alpha, lighten, darken, border, shadow, blur, brightness, contrast, dropShadow, grayscale, hueRotate, invert, filterOpacity, saturate, sepia, filters, linearGradient, asset, minmax, repeat, tracks, transition, animate, spacing, min, max, and clamp. linearGradient takes its direction as an Angle, linearGradient(90deg, from, to); CSS's to right keyword form has no Look spelling, and 90deg is that direction. hsl takes its saturation and lightness as a Percentage — hsl(200, 50%, 50%), each from 0% through 100%, the spelling CSS uses for those two slots — and refuses a bare number with the percentage it meant, which velar fix writes where the argument is a literal; the hue stays a number, because CSS's hue slot is one. min, max, and clamp take a Length or a Percentage in every slot — min(100%, 600px) is what these three exist for — and answer the widest of the two kinds they were given, so a call whose slots are all lengths is still a Length and a call that mixes them is refused by the one property that takes a length and no percentage, lineHeight. Each builder is an ordinary value, so const make = rgb aliases it and higher-order use retains the same checked signature.

Filter builders produce Filter; filters(...) composes a bounded list, and a value that is not one of them is refused with the builders that make one. A complete string such as "blur(26px) brightness(1.09)" receives advisory A16 and the rewrite to filters(blur(26px), brightness(1.09)), imports and all: velar fix applies it, and an editor offers it as a quick fix. Custom functions and filter text without an exact checked equivalent remain explicit free text.

token("--name") reads a design system's CSS custom property, and it is the one spelling that is legal in every Look property — metrics, colours, shadows, transitions, fonts, the free-text properties, and keyframes: stops alike:

import {token} from "velar/look"

export const shellChrome = look:
    width = token("--shell-sidebar-expanded-width")
    borderRadius = token("--ui-radius-panel")
    boxShadow = token("--shell-sidebar-shadow")
    transition = token("--ui-transition-fast")
    color = token("--ui-color-foreground")
    fontFamily = token("--ui-font-family")

The argument must be a literal string holding a CSS custom property identifier — -- followed by letters, digits, hyphens, or underscores. A computed name, an interpolation, or a binding is refused where the property is declared, because the reference is the only part of a design token the compiler can see: the value belongs to the design system, and a theme swapping it under the same name is what the contract is for. There is no fallback argument; a missing token is a defect where the token is defined. The call lowers to var(--name) while the module compiles, so the emitted module carries the text and makes no call.

animation is the one property token() does not reach: its value names a @keyframes rule, and Look generates those names from the keyframes: value that defines the motion. Write motion with keyframes: and animate(...).

A literal var(--name) string is refused wherever a Look value is checked, color("var(--name)") included, and velar fix rewrites it to token("--name") — adding the import when the module has none. The free-text properties keep accepting free text, so fontFamily = "var(--ui-font-family), system-ui" is a font stack rather than a token reference; a free-text value that is nothing but one reference receives advisory A12 and its rewrite.

Visual addition and subtraction require compatible dimensions. Length mixed with Percentage produces LengthPercentage; mixed units lower to calc(...). Multiplication and division accept a unit value and a finite scalar. Unit by unit multiplication, division by a unit, and arithmetic over colors or composite builders are rejected. Viewport thresholds must resolve at compile time to a local or imported const px, rem, or em token.

For one-off base properties, native elements and component hosts accept look:property="text" or look:property={expression}. The directives use the same checked camelCase property table as look: and form one anonymous Look after any look={value} composition. Duplicate directives fail checking and a null expression removes the composed property. Hooks, media conditions, pseudo-elements, and spreads remain exclusive to a named or local full Look; forms such as look:hover:color do not exist.

Across a component boundary the caller wins. A look or look:* written on a component invocation is composed after the look the component itself put on its host, so a property both of them set takes the caller's value while every property only one of them sets survives. That is what makes a component's visuals adjustable without a prop per property; a component whose visuals must not be overridden that way keeps them on an inner element rather than on its host.

A Look is an ordinary value with reference identity: == on two Looks compares identity, not declarations, so two identical look: literals are not equal even though they compile to one shared rule. Choose between named Look values, or compare the inputs that produced them.

Every property whose value is a CSS keyword carries its own closed set of keywords; a keyword property with no set of its own fails at module load rather than falling back to a shared list of common words. strokeLinecap accepts butt, round, and square, and the CSS-wide keywords — inherit, initial, revert, revert-layer, unset — which every keyword property accepts. A plausible strokeLinecap = "none" is a compile error instead of a declaration the browser discards, and borderStyle = "groove" and listStyleType = "upper-roman" are accepted because that property really has them.

A closed set holds whole CSS values, not tokens of one. scrollSnapType = "y mandatory", gridAutoFlow = "row dense", colorScheme = "light dark", touchAction = "pan-left", and backgroundRepeat = "repeat no-repeat" are written exactly as CSS writes them; there is no builder and no token grammar for a multi-part keyword value.

A property may publish part of its CSS grammar rather than all of it, and where it does, the part it left out is named in the diagnostic that rejects it, next to the import css unsafe boundary that reaches the rest. fontStyle holds the named slants and not an oblique 14deg angle; textOverflow holds clip and ellipsis and not a literal marker string; cursor holds the keyword cursors and not a url() image; overflow holds the single values and leaves the x y pair to overflowX and overflowY; the nine Box Alignment properties hold the alignments and not the safe and unsafe prefixes; transitionTimingFunction holds the same easing table animate(easing=) reads and not cubic-bezier(). Reading the boundary is never a matter of finding the table: a rejected value gets the nearest spelling when it is a near miss, the whole set written out when the set is small, and a description of what the set holds when it is not.

A shorthand and a longhand it writes cannot share one scope. A Look entry lowers to its own single-declaration rule, and the stylesheet orders those rules by where each property first appears in the module rather than by the block that wrote them, so padding = spacing(8px) beside paddingTop = 0px has no winner the block chose — a Look elsewhere in the module decides it. The pair is refused, and the refusal names the shorthand's longhands to write instead. The same holds for the look: directives on one element; style: directives are exempt, because they compose one inline style object where the browser's own source order settles it. font is not in the table at all, for the reason its row in the excluded list gives: its value is free text nothing checks, and a value that is not a legal font shorthand resets all seven of its longhands at computed-value time — including the ones written beside it. Write fontSize, fontFamily, fontWeight, lineHeight and their siblings, which are checked.

The three properties whose CSS grammar is <position> — backgroundPosition, transformOrigin, and objectPosition — are one kind reading one table, so each takes a length, a percentage, or any of the twenty-two placement values: objectPosition = 50% and objectPosition = "top left" are both ordinary. A two-axis length position is written the way a two-axis length is written anywhere else, with spacing(50%, 20%).

content is written as CSS text. A string value is emitted quoted — content = "•" produces content: "•" — and only the bare keywords none and normal pass through unquoted. attr(...), counter(...), url(...), and open-quote are therefore outside checked Look; reach them through the unsafe CSS boundary.

Checked style:property="text" and style:property={expression} directives exist only as a native inline-priority compatibility layer. They reuse Look's camelCase property table, property types, visual builders, safe serialization, reactive updates, and null removal, but write the real CSS property on the component host instead of producing a Look rule. Prefer Look: inline Style overrides normal Look and class declarations for the same property, including stateful Look rules. Raw style="...", Style objects, conditional directive names, and reusable Style values remain unsupported.

Motion: transitions and checked keyframes

Motion has two checked spellings:

  • transition (and transitionProperty, transitionDuration, transitionDelay, transitionTimingFunction) for state changes — a hover colour, an opening panel, a fading toast.
  • a module-level keyframes: value passed to animate(...) from velar/look for keyframe motion. The animation property accepts Animation, List<Animation>, or null; a raw CSS animation string is rejected, and the animation longhands (animationName, animationDuration, and the rest) stay outside Look because animate owns the checked contract.
import {animate} from "velar/look"

export const spin = keyframes:
    from:
        rotate = 0deg
    to:
        rotate = 1turn

const spinnerLook = look:
    if not motion.reduced:
        animation = animate(spin, 1s, easing="linear", loop=true)

export component Spinner:
    return <span look={spinnerLook} role="status" aria-label="Loading" />

Equal keyframe structures share one generated CSS name and one emitted rule, including across module boundaries. Bind a changing animation on the element with look:animation={active ? animate(spin, 1s) : null}; null removes the native animation. The charter's appendix to section 17 defines the stop grammar and every animate option.

A stop reuses the Look value checker, so a design token holds its meaning inside an animation: a const bound to a builder result, a unit token, a number, a keyword string, or a token("--name") reference reads in a stop exactly as it reads on a property, whether it was declared in this module or imported from a theme module, and a builder call in a stop may use named arguments. What a stop still needs is a value the compiler can resolve while it compiles — a stop becomes a real @keyframes rule, so a value that depends on run time, reactive state included, is rejected. A token() reference is resolvable in that sense even though the compiler cannot see the value behind the name: what it lowers to is var(--name), which is stylesheet text like any other.

import {Color, Length, animate, rgb, shadow} from "velar/look"

const glow: Color = rgb(120, 150, 255)
const lift: Length = 12px
const halo = shadow(0px, 0px, 18px, glow, spread=2px)

export const emerge = keyframes:
    from:
        opacity = 0.4
        translate = lift
    to:
        opacity = 1
        translate = 0px
        color = glow
        boxShadow = halo

export component Toast:
    return <p look:animation={animate(emerge, 500ms, easing="ease-out")} role="status">Saved</p>

The CSS @keyframes at-rule itself is still not a Look target. A stylesheet that must own its own keyframes — one shipped by a design system, or a name-dependent third-party class — crosses the unsafe CSS boundary like any other native CSS:

import css unsafe "./motion.css" before look

component LegacyBadge:
    return <span class="pulsing" role="status" aria-label="Syncing" />

That stylesheet is unchecked native CSS by design: its class names, keyframe names, and cascade are the stylesheet author's responsibility.

Media conditions cover viewport.width, viewport.height, scheme.dark, scheme.light, and motion.reduced — the last lowering to prefers-reduced-motion, so reduced-motion variants are expressible without leaving Look. Container queries, print, and orientation have no Look spelling in 0.10; reach them through the unsafe CSS boundary.

Form binding and the event object

bind:value and bind:checked bind one control; bind:group binds a set of controls that share one decision. A radio group's state holds the selected input's value, and a checkbox group's List<string> state holds the checked values, so checking and unchecking are membership changes. Every bind: target is a writable reactive location — a state name, or a field or index path rooted in one, such as bind:value={form.name} or bind:value={items[0]}. Each bound input identifies itself by its value attribute.

type Signup:
    email: string

component Form:
    state signup: Signup = {email: ""}
    state plan = "team"
    state extras: List<string> = []

    return <form>
        <input type="email" bind:value={signup.email} aria-label="Email" />
        <input type="radio" value="solo" bind:group={plan} aria-label="Solo" />
        <input type="radio" value="team" bind:group={plan} aria-label="Team" />
        <input type="checkbox" value="digest" bind:group={extras} aria-label="Digest" />
    </form>

An event object carries typed event fields only — type, defaultPrevented, preventDefault, stopPropagation, and the fields of its specific event kind. A KeyboardEvent carries key, code, repeat, isComposing, and the four modifier flags; a PointerEvent carries its coordinates, button state, and the same flags; an InputEvent carries inputType, data, and isComposing. isComposing is on both event kinds that can arrive mid-composition, and a handler that acts on Enter has to read it — an input method commits a candidate with Enter, so a composer that does not ask sends a half-typed word. It deliberately carries no target or currentTarget: an untyped element reference inside a handler is how DOM reading leaks back into application code, and the value the handler wanted is available through a binding. Read control values with bind:, reach a specific element with ref={element}, and read a whole form at submit time with velar/forms. An event handler runs for effect and returns null; because {} after => builds an empty record rather than an empty block, a no-op handler is written () => null.

A base Look selector and a simple class selector have equal specificity, so their winner is determined by ordinary CSS source order; before look and after look imports make that order explicit. Compound selectors, stateful Look selectors, inline Style, and !important participate in the normal CSS cascade rather than receiving a separate Velar priority system.

Official Web modules validate option records as own enumerable data fields and copy dense Lists before using them. Router, Forms, HTTP, Storage, Files, and Realtime share those guards rather than maintaining target-specific object walkers. The guards capture their Object, Array, Set, Symbol, and Reflect operations when each generated module initializes, so JavaScript loaded later cannot replace a constructor or prototype method to bypass unknown-field, accessor, sparse-List, or reactive-List handling. This is a runtime ownership rule, not a new source-level collection type: ordinary VelarScript records and Lists keep their documented identities.

Resources, actions, derived values, and watches

A resource loads once and reloads only when asked

This is the most common source of surprise in a data-driven component, so it is stated plainly: a resource does not refetch when its inputs change.

  • The initializer runs when the component's scope mounts, not when the declaration is reached. Before that, value is null, loading is true, ready is false, and error is null.
  • The initializer is a load, not a formula. When a prop or state it reads changes, nothing reloads: the old value stays on screen and loading does not turn back on. A component rendered with userId="alice" and then updated to userId="bob" keeps showing alice's data until something calls reload().
  • reload() re-evaluates the initializer against the inputs it reads now and answers Promise<null>. It sets loading and clears error as it starts.
  • Every load supersedes the previous one. A result that lands after a newer reload started is discarded, as is one that lands after the component is destroyed; a reload on a destroyed resource is a resolved no-op.
  • A failed load keeps the last successful value and publishes the failure in error, with ready true and loading false. The same failure is reported through velar/app under phase resource with the resource's declared name as the detail, so error and the application error chain agree.

Refetch-on-change is therefore written, not assumed. The spelling is a watch on the input plus a reload(); because a watch body is synchronous, the reload is started with the detached detach statement:

component Profile(userId: string):
    resource profile: User = loadUser(userId)

    watch userId:
        detach profile.reload()

    return <section>
        {profile.loading ? <p aria-busy="true">Loading…</p> : <h2>{profile.value?.name ?? "Unknown"}</h2>}
        {profile.error != null ? <p role="alert">{profile.error.message}</p> : null}
    </section>

Watch the narrowest thing that should trigger a refetch — one prop, or a record of query inputs — and remember that a deep mutation inside a watched value is itself a change. Watch the input, never the result: a watch on the resource's own value, loading, ready, or error whose body reloads that same resource is refused where it is written, because a reload writes exactly those fields and every completed load would re-trigger the watch that started it.

Actions run in parallel

An action does not serialize, queue, or debounce its calls. A second call starts while the first is in flight, and both run to completion.

  • pending is true while any call is active and becomes false when the last one settles, so it is a "busy" flag rather than a per-call state.
  • error belongs to the newest call: it clears when a call starts and is written only by the newest generation. An older failure that a newer call superseded is still reported through velar/app exactly once, with the action's name as its detail, but it does not own the public error field.
  • Every failure both reports and rejects the call, so a caller that owns recovery uses try/catch.
  • After the owning component is destroyed, calling the action rejects with an owned Error instead of starting application work.

When overlapping runs are wrong for an operation — a save, a submit — the application says so: disabled={save.pending} on the control, or an explicit if not save.pending: guard.

Derived expressions are checked for shape, not for purity

A computed declaration requires a synchronous expression — that is the whole requirement. The expression may write state, and the write publishes like any other. The compiler does not prove purity, so the runtime budget is what stands between an impure derivation and a frozen page: a derived value, render, or watch that invalidates itself more than 100 times is stopped and reported through velar/app. Keep derived values pure regardless — a derivation that mutates is a side effect hiding inside a cache, and watch is the declaration that says so out loud.

Watch forms and lifetime

watch subject: runs its body when the tracked value changes. A subject names a place in the reactive graph, not a computation: the name of a state, a computed, a prop, or a resource field, or a read path out of one through member access and indexing. watch items[0].done: and watch rows[i].cells[j]: are accepted at any depth, and the index may itself be reactive. An operator or a call in the subject is refused; the refusal echoes the expression that was written and names the computed declaration that replaces it. Calling a declared computed is answered by the rule that reads one bare — the parentheses go, and the subject is then legal.

A subject must also be able to change, and that question is asked first, so one shape never draws two messages. A literal, a plain const of a primitive, and an expression built only from those are refused rather than compiled into a body that never runs, and that refusal offers no replacement, because no spelling would fix it. A reader that was not called names a value that never moves either, and it is refused with the declaration it needed — computed name = reader(), then watch name:.

watch subject as current, previous: names the new and old values; both names are required with as, so a body that needs only the new value writes as current, _. The subject is read immediately to register the dependency and record the baseline — the body does not run for that first value. For a deep mutation, current and previous are the same reference.

A watch may write state and declares nothing to do so. Within a flush, watches run in the order they were written — source order in one module, mount order across instances of one component, module initialization order across modules — and two watches that write one state both take effect in that order rather than being refused.

Three shapes are refused where they are written, because each re-triggers the watch on every run and nothing ends it: a body whose top level unconditionally writes the watch's own subject or any part of it (watch count: count = count + 1, watch form: form.name = value, including a compound assignment and a mutating call on the subject or on a collection inside it); a watch on a resource field whose body reloads that resource; and a watch that unconditionally starts an action or an async def of the same module whose own top level writes the watched value. Everything else stays legal — a write under a condition, a write of another state, a write two calls away, a writer in another module — and the runtime budget is what stands behind it. That budget is spent per task, so a cycle chained through microtasks is stopped and reported like a synchronous one. A cycle that crosses the network is not, and cannot be: its write arrives in a later task, with nothing to connect it to the flush that started the load. The rule that covers what no budget can see is the same one the resource section states — watch the input, not the result.

A watch runs before the DOM its own change produces. Every computed and every watch settles to a fixed point first, and only then is the document written, once. That is deliberate — it is what stops a corrective watch from rendering the value it was written to correct — but it has a consequence that is easy to meet and hard to diagnose: a watch body reads the layout the page had before the change, not the layout it is about to have. scrollMetrics inside a watch answers the old contentHeight; measure answers the old box. Nothing reports an error, because nothing went wrong — the numbers are simply the previous frame's. The symptom is silence: scroll anchoring computed from them restores a position that was already correct, so a reader is never thrown, and the page just quietly stops doing the thing the watch existed to do.

Layout is read after a frame, not after a flush. A watch body is synchronous, so the read moves into a detached detach statement that waits first:

import {frame, scrollElementTo, scrollMetrics} from "velar/browser"

component MessageList(items: List<string>):
    let list: Element? = null
    const anchoredFromBottom = 0

    action anchorAfterPaint():
        await frame()
        if list != null:
            const metrics = scrollMetrics(list)
            scrollElementTo(list, 0, metrics.contentHeight - anchoredFromBottom)

    watch items:
        detach anchorAfterPaint()

    return <div ref={list}>{items.map(item => <p key={item}>{item}</p>)}</div>

tick() answers when the flush has settled and the DOM is written, which is the right wait for reading rendered text or structure. frame() from velar/browser answers after the next paint, which is the right wait for reading geometry — the browser has not laid the new nodes out until then. The same rule applies outside watches: scrollElementTo followed immediately by scrollMetrics reads the position the element had before the scroll.

A component watch is disposed with its component. A module-scope watch is never disposed: like a module action, it lives for the life of the page. That is the right lifetime for an application-wide fact — persisting a store to velar/storage, syncing a document title — and the wrong one for anything a component owns.

mount and tick

mount(node, target) attaches one root and returns null; target is a CSS selector string or an element. The root is constructed synchronously, so a direct await in the argument is rejected — await module-level preload work into a binding first. tick() answers Promise<null> that resolves after the pending reactive flush settles, and rejects if that flush reported a failure no handler claimed, so an awaited tick() cannot step over a broken update. Every caller awaiting that flush is a claimant, in every host: the flush's first unclaimed failure is delivered to every tick() pending on it wherever the program runs, and two callers awaiting one broken flush both reject with it. A claimed failure goes nowhere else — the report channel does not also receive it. Anything with no claimant goes to the host — the browser's error event, or the report channel elsewhere — and that covers both a failure raised while no tick() is pending and a second failure in a flush whose pending callers are already rejecting with the first. A tick() awaited after the flush was never pending on it: it claims nothing and resolves. Those two names are reserved in a Web module and cannot be shadowed by a local binding.

One component instance mounts exactly once, and the second mount of the same instance is refused explicitly rather than moving DOM silently. "Explicitly" is the error chain, not the caller: the failure is reported through velar/app in the mount phase carrying Cannot mount a VelarScript component more than once, and mount returns as it always does — a try: around the second call never reaches its catch, because nothing was thrown at it. Every mount failure is a report for the same reason: the one handler that owns the application's failures is where an author reads them, and a mount that half-succeeded has nothing useful to hand back to its caller.

Performance contracts

computed name = value is the single spelling that declares a derived value: there is no function form, no second memo spelling, and no manual batching API. Where a callable is what a receiver wants, write an ordinary def that reads the declared value; the cache stays the computed's. Repeated-computation and assignment-coalescing behavior below is a compiler and runtime contract rather than additional application controls. Removed experiments do not remain as aliases — memo, batch, and cached are ordinary identifiers, while computed(...) written as a call is answered with the declaration form rather than with an unknown name.

Synchronous assignment bursts publish once

Consecutive synchronous state assignments without an intervening read of an invalidated derived value publish once: every affected derived value, watch, and render observer re-runs a single time per burst, delivered on the microtask flush after the synchronous work completes. Reading an invalidated derived value synchronously refreshes it immediately; the pending flush does not recompute it again, and changed results still reach other observers. Assignments still commit their values immediately — a read between two assignments always sees the latest value, and derived invalidation is synchronous through every downstream derived value, so a same-turn read of a derived chain cannot observe an intermediate stale cache and state never tears — and the burst may span ordinary function calls; the synchronous extent is what counts. Render and watch observers are still notified only after the recomputed public result actually changes.

def commitSend(userMessage: Message, reply: Message):
    messages = [...messages, userMessage]
    messages = [...messages, reply]
    streamingMessageId = reply.id

All three assignments above publish as one commit: a derived value reading messages recomputes once, not twice, and a watch on it fires once. The boundary of the contract is the synchronous extent: a burst spread across await boundaries publishes per assignment, and a throw mid-burst does not tear state — what was assigned before the failure still publishes on the flush.

Deep state tracks the property or collection key that was read

Only ordinary mutable records receive lazy reactive proxies. List, Map, and Set keep their native identities and are tracked by compiler-owned collection operations. Classes, functions, DOM and other host objects, frozen records, and non-extensible records are never wrapped. The proxy cache and dependency graph have one versioned owner on globalThis, so application modules and lazy chunks share one raw/proxy identity.

A class instance in state follows from that rule, and the consequence is worth stating outright. state box = Counter() holds the instance itself, so computed shown = box.value reads a field nothing is tracking: box.bump() changes value and publishes nothing, and shown keeps the number it first read for as long as the page lives. Only replacing the cell publishes — box = Counter(). Three readers reach a field that way and all three are permanently stale: a computed, a watch subject, and a DOM interpolation such as <p>{box.value}</p>, whose text node is written once and never again. A development build detects the read and says so once per place, class and field, in the frozen-read detector's channel, naming those three and the reader it was; a production build carries none of it. Where a field is meant to be followed, hold it in its own state and let the class take it as an argument.

Four things that report says, because a report that names the wrong thing costs more than none. It is made when the field changes under a reader that cannot follow it — the frozen-read detector's own rule, and the reason a const field is never reported: nothing can change it, so its read can never go stale. It names the place, not only the cell: an instance below the cell is reached through a path, and state holder = {box: Box()} reports holder.box. It gives the write that publishes for that shape — box = Counter(...) for the cell itself, holder = {...holder, box: Box(...)} for an instance in a record below it, boxes[0] = Box(...) for one in a List — because a remedy the compiler then refuses is not a remedy. And it names the member the reading line contains as well as the field underneath it, so get shown() backed by inner reports both rather than a name the author's line never mentions.

The graph does not rediscover JavaScript collection methods while the app is running. A generated reactive module captures the Set, Map, WeakSet, WeakMap, Array, Object, Proxy, and Reflect operations used by its cells, observers, queues, and deep-record proxies when that module initializes. Replacing an ambient constructor, static helper, or collection prototype later cannot redirect subscriptions, cleanup, batching, deep mutation, or watch equality.

A reactive observer subscribes to the property or collection key it reads. Nested mutations bubble a version change to owning state for deep watches, but unrelated property and Map-key consumers remain clean. List size and shifted indexes, Map/Record key structure, and Map values are distinct dependencies: inserting at index i invalidates only tracked indexes at or after i, while updating an existing Map value does not invalidate keys() or size. Clearing a Map, Set, or Record invalidates every concrete key that has an active subscriber as well as its iteration and structure dependencies. Temporary Lists produced by map/filter do not become permanent parents of every item they expose, and replacing a state root detaches the old root from the deep parent graph together with everything only that root owned, so the canonical settings = {...settings, label: next} update releases the replaced value instead of leaving one dead owner per update behind every surviving descendant. Deep-change bubbling therefore costs the same after ten thousand root replacements as after the first. Keyed JSX rows receive reactive record items even though dense-List validation intentionally reads raw descriptors.

state messagesById: Map<string, Message> = Map()
state latestBySession: Map<string, Message> = Map()

def appendChunk(replyId: string, chunk: string):
    const reply = messagesById.get(replyId)
    if reply != null: reply.text += chunk

component SessionList:
    return <ul>{sessions.map(session =>
        <SessionRow key={session.id} preview={messagePreview(latestBySession.get(session.id))} />
    )}</ul>

Appending a chunk performs one Map.get and one property assignment. Only the message body and the row whose preview read that message's text are invalidated; no identity-keyed derivation cache can become stale. Serialization, storage, HTTP, realtime, forms, unsafe bridges, and equality/test boundaries unwrap through the same toRaw operation before validation. Map keys and Set members are unwrapped before lookup so a reactive record and its raw identity cannot split membership.

velar/app

import {onError, reportError} from "velar/app"

component RuntimeStatus:
    state message = "ready"

    def capture(phase: string, detail: string): message = phase + ":" + detail

    const stopErrors = onError(report => capture(report.phase, report.error.message))

    def failDeliberately(): reportError(Error("Manual failure"), "manual", "diagnostic action")

    @cleanup:
        stopErrors()

    return <button on:click={failDeliberately}>{message}</button>
  • onError(callback) observes application failures and returns an explicit cleanup function. Reports contain error, phase, detail, component, and timestamp; foreign JavaScript values are normalized to Error.
  • reportError(error, phase="manual", detail="") deliberately submits an application error without throwing it again.
  • Error metadata is controlled text, never an implicitly converted object. Reports cap phase, detail, and component lengths, reject accessors and unknown fields at dynamic boundaries, and reuse one versioned runtime owner across application modules and lazy chunks. Error timestamps and every framework microtask use the Date/queue operations captured with that runtime; later ambient replacement cannot stall reactivity or falsify a report. Error identity, report freezing, metadata discovery, timestamp validation, handler registration, and native Promise rejection observation are captured at module initialization too, so post-load changes to Error/Object/Number/Promise/Set cannot redirect either manual reports or managed asynchronous failures.
  • The compiler reports failures from initial mount, reactive render and synchronous watch blocks, synchronous or asynchronous events, @mounted, and @cleanup. A detached detach statement reports its rejection through this same chain under the distinct detached phase; with no handler installed the failure surfaces through the host runtime, never silently.
  • Event handlers and lifecycle callbacks are non-tracking execution boundaries. Reads performed by @mounted or @cleanup cannot become dependencies of an enclosing conditional/keyed render that happened to mount or destroy the component, and a synchronously dispatched event cannot inherit a framework observer. State writes still notify their actual render/watch consumers.
  • JSX rendering and derived expressions are synchronous. Async component data belongs in resource; explicit UI operations belong in action, declared in the component that triggers them or at module scope when a shared store owns the operation and its pending/error surface; setup that must finish after insertion belongs in @mounted. Process- and page-lifetime work with no UI surface uses the detach statement, which runs a checked Promise<null> expression detached and reports failure through the velar/app chain with the detached phase.
  • An action failure reports exactly once, through the action phase with the action's name as its detail -- including a failure superseded by a newer call of the same action, whose report carries the same detail while only the newest generation owns the public error field. The event or detached observer of that same rejection skips the already-reported failure instead of reporting it a second time, and the caller still receives the thrown error.
  • A derived-value failure is cached as part of the derived result state and is rethrown to its managed consumer. Recovery from failure to a value is a real result transition even when that value equals the last successful value, so downstream derived chains wake and recover without publishing a duplicate watch value. Once the last consumer is disposed, the derived value detaches from its upstream dependencies and recomputes only if read again.
  • resource, action, and tick use the Promise constructor plus resolve/reject/then operations captured when the generated Web module initializes. Resource/action surface construction uses the same captured Object operations as the reactive graph. Later replacement of those ambient constructors, static operations, or prototypes cannot turn an owned async start into a synchronous escape or redirect its completion. A disposed resource reload remains a resolved no-op; calling a disposed action remains a rejected owned Error.
  • Managed callbacks from browser/media/online/visibility watchers, storage watches, WebSocket handlers, and server-sent-event handlers report both synchronous failures and rejected promises through the same velar/app channel. Only actual Promises are observed; arbitrary thenables remain ordinary callback results and their properties are not probed. Failures do not escape as unowned native event failures.
  • Root mounting is lazy. If the initial render fails, the application receives the report and renders a compiler-owned accessible fatal state instead of a blank page. That covers every initial-render path in every build: a setup throw, a dynamic or keyed region that throws while it is first constructed, a mount target that does not exist, and a root written into a module binding -- const root = <App /> -- whose construction throws while the module evaluates. A missing target reports through the mount phase and renders the fatal state into the document body, since the requested target is exactly what is missing. A region is the one of those that fails inside a page that otherwise works, so it renders the same element -- role="alert", data-velar-fatal, one sentence naming the failure -- scoped to the position it could not build, reading "This part of the page could not start: " and the message. It renders in the namespace of the host it stands in: inside an <svg> the same marker is a g carrying the role and the attribute with the sentence in a text child, because an HTML element inside SVG content is laid out by no browser and a marker nobody can see is the blank page this promise is made against. Its isolation is unchanged: the siblings around it mount and keep updating, and the report stays in the render phase. A module-level root that fails to construct does not stop module evaluation: the rest of the module runs, so the @main region still installs its error handlers and still calls mount, and the failure is reported once through the mount phase and rendered into that mount's target. A failed root that is never mounted renders the fatal state into the document body instead, unless another root has already mounted successfully. Component setup and initial JSX construction are transactional: failure runs sibling cleanup, destroys the incomplete scope, and preserves the original error. Dynamic and keyed updates build the replacement first, so a failed update retains the last valid DOM and discards its incomplete scope. Ordinary and keyed JSX Lists both read one checked dense List snapshot; mutation during rendering and JavaScript iterator overrides cannot change the values participating in that update. A keyed update moves only the rows whose position actually changed: a row already standing where the new order wants it is left attached, so the focused <input> in it keeps focus, an in-flight IME composition survives, and no transient subtree state is reset.
  • Emitted JSX and runtime-implemented velar/web components use the same small DOM host ABI. A generated module captures Document/Node identity, node factories, mount/removal operations, bounded child collections, and the Array/Set/number/string operations needed by DOM rendering when it initializes. Code loaded later cannot replace document, Node, or their prototype methods to redirect element/text/fragment creation, mount, destroy, Router, Lazy, or dense JSX List expansion. Explicit non-browser test hosts are data-only seams; accessor-backed substitutes are not invoked.
  • Root construction passed to mount is synchronous so the runtime can own its failure transaction. Await module-level preload work into a binding before calling mount; component data continues to use resource.
  • A direct JSX component root remains a stable host. Any other WebNode root expression, including a conditional root, owns a dedicated dynamic child scope: updates construct the replacement transactionally, destruction removes the currently selected nodes, and component setup is not rerun. One component instance may be mounted exactly once; a repeated mount fails explicitly instead of silently transferring or losing DOM ownership.
  • unsafe:html is an explicit wholesale innerHTML assignment on every reactive update. It does not morph DOM or preserve descendant identity. Incremental Markdown therefore requires a parser-owned stable-prefix/dirty-tail contract and a separately designed DOM morph boundary; it is not inferred from an arbitrary trusted HTML string.
  • Cleanup remains the sibling @cleanup block, not a React-style effect. Its independent cleanup steps continue after one fails, and every failure is reported. @mounted may await asynchronous work; @cleanup remains synchronous and should start no unowned background work.
  • An automatic failure with no installed handler is surfaced through the host runtime rather than silently discarded. Error handlers therefore own their lifetime and must be removed during cleanup.
  • Reactive work is budgeted in both directions. A render or watch that invalidates itself more than 100 times is stopped and reported, and one task may run at most 100,000 observers: a pair of watches that write each other's state, which no single-observer cap can see, is stopped and reported through the same velar/app chain with phase update instead of freezing the page. Observers still queued when that budget is exhausted are stopped with it, since they are the ones a runaway flush would resume. The budget is per task rather than per flush so that a cycle chained through microtasks — a watch that detaches work which awaits an already-resolved Promise and writes back — meets one budget rather than a fresh one every round; it carries from one flush to the next only when an observer run started that asynchronous work, so a loop that simply writes and awaits its way past 100,000 observer runs in one task still meets a fresh budget every flush. A macrotask boundary ends the window, so writes driven by a timer, an event, or network I/O start a new budget and an animation that writes state on every frame is never stopped.
  • An application may install at most 1,000 error handlers. Manual report phases are limited to 256 characters and details to 64 KiB before entering the shared error channel.

velar/config

{
  "web": {
    "publicConfig": {
      "apiBase": "/api",
      "releaseChannel": "preview"
    }
  }
}
import {has, keys, publicConfig} from "velar/config"

type RuntimeConfig:
    apiBase: string
    releaseChannel: string

const config = publicConfig(RuntimeConfig)
  • publicConfig(Type) validates the complete manifest value through the same VelarScript type declaration used everywhere else and returns the named type. has(key) and sorted keys() support optional capability discovery.
  • The manifest and the declared type are both compile inputs, so the match is proved at build time: velar check and velar build report the first field that is missing or does not match, at the publicConfig(Type) call, naming the two edits that resolve it. Runtime validation stays for the manifest a build baked in and someone edited afterwards.
  • Only velar.json.web.publicConfig is read. It must be a JSON object, is limited to 64 KiB, rejects non-finite/non-JSON values and the reserved keys __proto__, prototype, and constructor, and is recursively frozen.
  • Public configuration is baked into the content-hashed application entry at build time. VelarScript never implicitly reads .env, process.env, server configuration, or secrets, and this boundary must contain public values only.

velar/web

import {Head, Link, NavLink, RouteContext, Router, announce, back, currentRoute, domId, forward, lazy, navigate, redirect, reload, route} from "velar/web"

component PageLoading:
    return <main aria-busy="true">Loading…</main>

component PageFailed(error: Error):
    return <main role="alert">{error.message}</main>

const Reports = lazy(() => import("./pages/reports.vel"), "Reports", PageLoading, PageFailed)
  • route(path, component) declares an application route. A pattern is a pure pathname beginning with /; query/hash text, backslashes, empty segments, and a redundant trailing slash are rejected. It may contain :name parameters or one whole terminal * segment. The wildcard value is exposed as route.params.get("wildcard"), so a named :wildcard parameter cannot be combined with *.
  • RouteContext is an importable named data type with path, string params/query Maps, and hash. A route component may require only its route: RouteContext prop; invalid path parameters, repeated parameters, non-terminal wildcards, non-components, wrong route props, and other required props fail checking before browser execution. Dynamic route strings retain the same runtime validation. A route is the record route(...) returns, so the same record written directly into routes — {path: "/items/:id", component: ItemPage} — is the same route and is checked the same way: the path pattern where it is a literal, and the component's contract. Where the list is not written out at the Router, the checks run when the router builds its table, and a bad path or a component that is not callable refuses the table rather than rendering.
  • lazy(loader, exportName, loading=null, failed=null) returns the exported component with its original prop contract. It owns one cached successful module load, retries after a failed load, mounts only the active child, and destroys loading or resolved children when the route leaves. The loader must be a zero-argument checked dynamic VelarScript import and exportName must be a literal component export. Failures report through velar/app with the resource phase and render either the supplied failure component or an accessible default alert.
  • The lazy boundary also owns failures after the module has loaded: if the selected component throws during construction, its transactional cleanup finishes before the same lazy failure view is rendered. A failing custom failure component is reported as render/lazy-fallback:<name> and replaced by a built-in accessible alert rather than becoming an unhandled rejection. Loading and failure fallbacks must actually return components; a callable returning an arbitrary DOM value is rejected.
  • Router(routes, fallback=null) renders the first matching component. Its route context contains application-relative path, decoded params, parsed query, and hash. When no route matches, an omitted fallback renders a small accessible Page not found view instead of an empty router. A custom fallback is a checked route component: it may accept route: RouteContext and cannot require unrelated props.
  • A Router accepts at most 10,000 routes and compiles their matchers once when it is constructed. Route/navigation URL text is limited to 2 MiB, route patterns to 8,192 code units, and decoded query snapshots to 100,000 fields; a hostile direct URL cannot force an unbounded route table or repeated pattern compilation.
  • Browser path, query, and hash values must already be strings; routing never calls conversion hooks on malformed host values. RouteContext.is/parse inspect only ordinary data fields, reject accessors, and bound the combined text stored in params and query Maps.
  • A percent-encoded route parameter that cannot be decoded is treated as a non-match and reaches the normal 404 fallback. A malformed direct URL cannot turn an otherwise recoverable unknown route into an application-fatal render.
  • A mounted Router constructs the target component before destroying or replacing the active page. If construction fails, the target's transactional cleanup completes, the prior page remains mounted, and velar/app receives phase render, detail router, component Router. The URL reflects the attempted navigation and another navigation may recover normally. Failure of the initial route is rethrown to the root mount boundary so the accessible fatal state is not replaced by an apparently successful empty router. The runtime also validates a target returned through an unsafe JavaScript adapter before commit; an invalid target follows the same retained-page failure path.
  • Link preserves native anchor behavior and intercepts only an unmodified primary click on an internal application path. Click metadata and preventDefault come from captured native Event/MouseEvent operations; a synthetic accessor-backed click is reported through velar/app and cannot execute an override before navigation.
  • NavLink(to, exact=false, replace=false) retains the same native click behavior for internal application paths and owns aria-current="page" while its target is active. Non-exact links match descendant paths except that / matches only the application root. Matching remains application-relative under web.base, normalizes trailing slashes, follows history changes, and releases its listener with the component.
  • A Link or NavLink target is an application path or an http/https URL. Any other scheme is refused with a TypeError naming the scheme, because a Link writes its target to a real anchor and lets native activation run it: javascript: and vbscript: are code rather than locations, and a value that arrived as data must not become code because it reached an anchor. The scheme is read the way the user agent reads it, so leading whitespace and embedded control characters hide nothing — java\tscript: is javascript: to the browser and to this check alike. Write a plain <a href={...}> element for a mailto: or tel: target; the JSX URL attributes take the wider allowlist.
  • navigate(to, {replace, scroll}), redirect, back, forward, and reload expose intentional history operations. currentRoute() returns a typed snapshot of the current application-relative route. The snapshot is frozen, and reading it is live: a currentRoute() read inside a reactive position is a dependency on the history exactly as reading a state cell is a dependency on that cell, so chrome outside the Router — a title bar, a breadcrumb, a sidebar marking the open page — re-renders on navigation without the mounted page publishing its route back out. One history subscription serves Router, NavLink, and every currentRoute() reader; a read outside a reactive position subscribes to nothing. Navigation options are a data-only record with boolean replace/scroll fields and are fully validated before history or scrolling changes. Location getters, History methods, URL parsing, PopStateEvent dispatch, animation frames, scrolling, and route listeners use the host ABI captured when velar/web initializes; later global replacement or instance shadowing cannot redirect navigation.
  • Head owns title, description, canonical, robots, image, themeColor, and the document language tag: it takes them when it is inserted and gives them back when it is removed. Its props are live, not snapshotted. Document metadata is rendered output, so it follows the ordinary rule that rendered output re-reads the state it depends on: <Head title={f"Inbox ({unread})"} /> tracks unread, and the document title changes with it. The props are validated where they always were — once during construction, before any document write — and validated again on every update. Router, Link, and NavLink are live in the same way and for the same reason: one rule covers all four framework components, so a Link whose to is state moves with it, a NavLink moves its aria-current with it, and a routes table built from state re-renders the position it fills. A constant routes table subscribes to nothing and recomputes nothing. The favicon is not among the facts a Head owns: it is a build-time document fact and belongs to web.icon in the project manifest.
  • announce(message, priority="polite") writes to a compiler-owned live region; priority is polite or assertive.
  • domId(prefix="velar") returns an application-local, monotonically unique DOM ID for component setup. It exists for accessible aria-labelledby, aria-describedby, and label/control relationships without requiring a cryptographic UUID. Prefixes must start with a letter, contain only letters, numbers, _, or -, and are limited to 64 characters.

Dynamic Head, Router, Link, and NavLink prop records accept only their documented enumerable fields, with one field-level allowance: an enumerable getter is how a live prop is published, so a getter is accepted and read on every update. The rest of the rule holds unchanged — a plain record, no symbol fields, no field outside the declared set — and each value is checked and bounded on every read rather than once. Symbols, unknown fields, invalid booleans, and malformed route Lists still fail before DOM or history effects; JSX child Lists use the same dense data-element boundary as Core. The allowance is for props and nothing else: a route entry inside a Router routes List is an ordinary data record and still refuses accessors, as navigate's options record does.

Native SVG JSX

Inline <svg> is part of native VelarScript JSX rather than an image-string or framework adapter. SVG namespace ownership crosses nested elements, reactive branches, keyed Lists, fragments, and ordinary or lazy user components. A lazy SVG boundary uses a native <g> host and forwards the namespace to loading, failure, and resolved components instead of inserting an HTML wrapper. <foreignObject> owns an HTML child subtree, while a nested <svg> re-enters SVG. Scoped CSS, reactive attributes, events, stable keys, refs, and component cleanup retain their ordinary VelarScript behavior across the boundary.

SVG roots require an accessible name through <title>, aria-label, or aria-labelledby, unless a decorative graphic explicitly uses aria-hidden="true". Case-sensitive SVG attributes are preserved, and xlink:*/xml:* attributes use their platform namespaces. API Dashboard is the real production-project gate: it renders data-driven SVG bars through a checked dynamic component chunk and embeds an HTML summary through <foreignObject>.

With web.base set to /studio/, navigate("/settings") resolves to /studio/settings while the router still matches /settings. Development provides deep-route fallback; a production host must apply the emitted SPA fallback contract.

velar/http

import {HttpAbortError, HttpResponseError, HttpTransportError, HttpTransportPhase, http} from "velar/http"

const request = http.get("/api/profile", {timeout: 5000})
const profile = await request.parse(Profile)

Incremental text bodies do not require buffering the whole response:

const request = http.get("/api/events", {timeout: 120000})
async def consumeEventChunk(chunk: string):
    print(chunk)
    return null
await request.streamText(consumeEventChunk)

Multipart uploads use an explicit body builder and opaque file records:

import {formBody, http} from "velar/http"
import {pick} from "velar/files"

const selected = await pick({accept: "image/*", multiple: true})
const body = formBody()
body.field("title", "Profile images")
body.files("images", selected)
const result = await http.post("/api/images", {body: body}).parse(UploadResult)
  • request, get, post, put, patch, delete, and head create lazy requests. Options include string Map headers, body, timeout, maxBytes, credentials, and cache mode. maxBytes defaults to 16 MiB and may be an integer up to 64 MiB. Timeout defaults to 120,000 milliseconds on every HTTP target; it must be an integer from 0 through 600,000, and 0 explicitly disables the deadline.
  • Options are plain data records with only documented fields. Accessors, symbols, class instances, unknown fields, non-string headers, invalid Fetch methods, and forbidden CONNECT/TRACE/TRACK fail at request creation. Credentials are omit, same-origin, or include; cache is default, no-store, reload, no-cache, or force-cache.
  • Requests expose response, json, text, bytes, streamText, blob, parse(Type), and cancel. Responses expose typed status, URL, and header fields plus the same body readers. A response has no ok field: response() throws HttpResponseError before it answers for any non-2xx status, so every response an author can hold succeeded, and a field asserting that would be a constant. Handle the failure where it is raised — catch failure: then if failure is HttpResponseError: — and read failure.status there. bytes() returns the target-neutral immutable Bytes snapshot. streamText incrementally decodes valid UTF-8 chunks and awaits each consumer before pulling the next chunk. blob() returns an opaque checked Blob, not any; it may be passed back as an HTTP body but does not expose the native browser object or arbitrary fields.
  • Fetch results are snapshotted and validated once before they enter the typed response object. Status/ok, status text, canonical URL, native headers, and body ownership cannot change between validation and use; response headers share the 100-field/64-KiB bound and returned URLs the 2-MiB URL bound. A response status must be an integer from 100 through 599, and ok must be exactly equivalent to the 200-through-299 range. Opaque or synthetic status-zero responses are rejected as invalid host metadata rather than entering HttpResponseError or body processing. The ok named here belongs to the validated host record, not to the response an author holds: it is an integrity check that rejects a host contradicting its own status, and it is never exposed as a field.
  • The HTTP module captures Fetch, Headers, native Response accessors, abort and timer operations, FormData, Blob, TextDecoder, and byte-array construction when it initializes. Later JavaScript replacement cannot redirect requests, intercept body assembly, or install response getters inside the typed snapshot path. Own accessors placed on a native Response are ignored in favor of the captured host prototype contract.
  • Response bodies are consumed through captured native stream operations and accept only real Uint8Array chunks. Each accepted chunk is copied after its size passes the running budget, and a pathological stream cannot exceed one million chunks. A declared or streamed body over maxBytes is cancelled before it can be materialized; successful bytes are cached so repeated text/json/blob reads are stable, and concurrent buffered readers coalesce onto that one body read. streamText remains an exclusive incremental reader; it does not silently duplicate an active stream. JSON remains subject to the separate strict 16 MiB JSON contract even when a larger text/blob budget was requested. A Content-Length attached to a response with no actual body, such as HEAD metadata, describes a representation and does not create a false size failure. Declared-length parsing is captured when the module is initialized, so later mutation of application string, regex, or reflection operations cannot disable the preflight.
  • Cancellation is idempotent and owns the whole lazy request. Cancelling before the first body reader prevents fetch from starting; cancelling an active request aborts it and immediately releases its owned deadline timer. Timeout ownership continues through streamText and all buffered body reads; it is not cleared merely because response headers have arrived. Cancellation and timeout reject with HttpAbortError, whose reason is "cancelled" or "timeout", so application recovery does not depend on browser-specific abort errors.
  • Native request or response-stream failures reject with HttpTransportError. Its typed phase is HttpTransportPhase.request or .response; the response phase may follow already-delivered streamText chunks. Fetch/protocol validation, UTF-8, bounds, and consumer failures are not relabelled as transport failures. velar/http does not retry implicitly; status policy, backoff, idempotency, and replay safety belong to the caller.
  • Object bodies are snapshotted and JSON encoded when the lazy request object is created, not later when a body reader starts Fetch. They receive an application/json content type unless one was supplied. The generated header is included in the same 100-field/64-KiB request-header budget. Non-2xx responses throw HttpResponseError with status, url, and an unknown body. Its URL is the final response URL after redirects; only a synthetic response without a URL falls back to the initial request URL. HttpResponseError is the outbound client failure; server routes use the separate HttpProblem semantic contract. This error is also why a response has no ok field: the non-2xx case throws here instead of answering a response.
  • A Bytes request body is sent as binary without JSON/Base64 conversion. JSON request bodies use the same strict lossless data boundary as velar/json: records, dense Lists, finite primitives, and null are accepted; Map, Set, class/function values, cycles, sparse Lists, and non-finite numbers fail before fetch starts. Other bodies are explicit text, Blob, or form bodies; primitives are not silently converted, and GET/HEAD bodies fail.
  • formBody() creates a multipart builder with field, file, files, remove, has, and names. String fields may be appended repeatedly; file accepts only one opaque record returned by velar/files.pick, and files accepts a list of those records. A forged structural object fails at runtime even if it resembles the public file metadata: native authority lives in a host-private shared WeakMap registry, not a copyable record field.
  • Field names/values and optional file names are actual strings. The browser owns the multipart boundary. Supplying a content-type header with a form body is rejected rather than risking an invalid boundary. Native File and FormData are not source-level APIs.
  • Headers are capped at 100 fields/64 KiB after generated headers, text and form values at 16 MiB of encoded UTF-8 transport bytes, and multipart bodies at 100,000 fields. JSON first follows its lossless structural/code-unit contract, then its serialized text follows the same UTF-8 transport-byte ceiling as a plain text body. Timeout uses the host timer range; these limits are checked before Fetch or FormData mutation.
  • parse(Type) uses the existing VelarScript type runtime validator; there is no second schema system. The browser still performs cancellation underneath, while VelarScript normalizes the observable failure contract. A dynamic invalid Type fails before a lazy request starts or a response body is consumed.

velar/storage

import {database, storage} from "velar/storage"

type Settings:
    theme: string

component PreferencesPanel:
    const preferences = storage.scope("studio")
    const stopWatching = preferences.watch("settings", Settings, (next, previous) => print(next), 262144)
    const settings = preferences.get("settings", Settings, {theme: "system"}, 262144)
    const cache = database("release-studio")

    async def save():
        preferences.set("settings", settings, 262144)
        await cache.set("settings", settings, 262144)

    @mounted:
        await save()

    @cleanup:
        stopWatching()

    return <p>{settings.theme}</p>
  • storage and session wrap local and session storage. Both provide typed get, JSON set, has, keys, remove, clear, watch, and scope.
  • get(key, Type, fallback=null, maxBytes=16777216), set(key, value, maxBytes=16777216), and watch(key, Type, callback, maxBytes=16777216) accept a positive integer encoded-value budget up to the 16 MiB hard ceiling. Named arguments may set maxBytes without supplying a fallback. The budget uses the same compiler-owned UTF-8 counter as HTTP and Text.utf8Size, including its treatment of surrogate pairs and unpaired surrogates.
  • A local/session read whose stored JSON text exceeds maxBytes returns the declared fallback without parsing it. A write serializes through strict JSON and throws RangeError before any storage mutation when the encoded text is over budget. A watch maps an individually oversized old or new value to null; it does not deliver partially parsed data. An invalid budget fails before reading storage, installing listeners, or opening an IndexedDB transaction.
  • Local/session storage areas, their native getters and methods, same-page event dispatch, and global storage-event listeners are captured when the module initializes. Later replacement of a storage global, prototype mutation, or instance shadow cannot redirect an official storage operation.
  • Read/watch validators must be actual compiler-known VelarScript runtime types even when invoked through a dynamic JavaScript boundary; an arbitrary object cannot silently disable validation or register a delayed invalid watch.
  • Record Types, runtime aliases, and enums use the same registered identity across compiled modules. Validation never probes a lookalike object's is/parse getters, and invalid Type, key, or value arguments are rejected before local/session storage is accessed.
  • storage.scope(name) and session.scope(name) create one compiler-typed key namespace. HMR recreates module state; only explicitly stored values persist.
  • watch observes same-page writes and browser storage events and returns an explicit cleanup function. A throwing callback or rejected callback promise is reported through velar/app with storage ownership; cleanup still removes the listener normally. Event payloads are read only through captured native browser getters or enumerable own data fields; a synthetic accessor-backed event is ignored without executing its getters.
  • database(name) provides asynchronous IndexedDB get, set, getBytes, setBytes, atomic batch, has, keys, remove, and clear. Its JSON get and set accept the same trailing maxBytes contract. Values are stored as canonical strict JSON text rather than arbitrary structured-clone objects, allowing an oversized value to be rejected before parsing. Typed reads use the same VelarScript type validator and fallback rules; values written directly by JavaScript in a different representation are foreign data and return the declared fallback. getBytes(key, fallback=null, maxBytes=16777216) and setBytes(key, value, maxBytes=16777216) use the shared immutable Bytes representation directly. batch([{key, bytes}, ...]) commits all binary writes/removals in one transaction; bytes: null removes that key.
  • IndexedDB quota, transaction, and version/open failures keep distinct StorageQuotaError, StorageTransactionError, and StorageUpgradeError identities. A failed atomic batch exposes no partial success.
  • The IndexedDB factory, request getters, database/transaction/object-store methods, key-list checks, and event listeners use the same captured host ABI. JavaScript may still expose an explicit data-only test double, but ambient or instance accessors are never treated as trusted database fields.
  • IndexedDB operations resolve only when their transaction commits, not merely when the individual request reports success. A later transaction abort is observable as the original host rejection. Failed/blocked opens reset the cached connection so a later operation may retry, and version changes or unexpected connection closes discard stale handles. A transaction-creation failure also resets the cached handle so the next operation can reconnect instead of remaining bound to a dead database object.
  • Storage, scope, database, and key names remain actual strings. Scoped browser keys have one bounded 4096-character path, and a key listing snapshots the host length once, validates every result, caps its aggregate text, and returns an application-owned mutable List sorted for deterministic behavior. Listings never invoke methods on host-provided key values. IndexedDB listings likewise reject malformed or non-string keys instead of coercing them.
  • Every write uses the strict velar/json data contract. Unsupported or lossy values that are visible to the compiler fail during checking; dynamic values are validated again at runtime before local/session storage or IndexedDB is mutated. IndexedDB accepts only its strict JSON text representation before Type validation, so a Date, Map, accessor record, object clone, or other foreign value written by JavaScript cannot enter Vel through a permissive runtime Type; the read returns its declared fallback instead.

velar/forms

import {checkedValue, clearError, clearErrors, errors, fieldValue, fieldValues, focusFirstError, numberValue, read, reset, setError, setPending, textValue, values} from "velar/forms"
  • values(form) returns Map<string, unknown> from native form data; repeated names become a list. fieldValue(form, name) reads one value.
  • textValue(form, name, fallback=""), numberValue(form, name), checkedValue(form, name), and fieldValues(form, name) expose common native form shapes without leaking FormData or forcing application code through unknown. Numbers use the same signed decimal/exponent grammar as number(text); blank, hexadecimal, numeric-separator, and non-finite text return null. Textual helpers reject native file values instead of coercing them silently.
  • read(form, Type) decodes a flat native form into the existing colon-form record type. It supports string, finite number, bool checkboxes, enums, List<string> repeated fields, and optional string/number/enum fields. Blank optional numbers and missing optional scalars become null; invalid required numbers, enums, native files, nested records, and unsupported collections fail explicitly. The compiler supplies the private decoder description and the existing Type.parse validator checks the result.
  • The runtime Type and compiler decoder description are validated as data-only contracts before FormData is constructed. Decoder Lists cannot be sparse or accessor-backed; names, kinds, fallbacks, and error messages are real strings, and invalid dynamic arguments cannot read or mutate the form first.
  • The module captures native form identity, FormData construction and reads, repeated-field Map operations, and strict decimal-parsing intrinsics when it initializes. JavaScript loaded afterwards cannot replace HTMLFormElement, FormData, their relevant prototype operations, or constructor Symbol.hasInstance to reject an already-owned form or intercept submitted values. This capture covers value extraction; error-node, focus, reset, and pending-state DOM mutations retain their separately documented lifecycle.
  • Submitted/decoder/error field names are limited to 1,024 characters, error messages and owned accessibility metadata to 64 KiB, and textual fallbacks to 16 MiB. Values returned from native form/error nodes are checked before becoming a VelarScript Map.
  • read does not create another schema language or infer business rules. Required text, trimming, minimums, custom messages, and submission behavior remain application code and ordinary HTML attributes.
  • setError, clearError, and clearErrors own field alerts while preserving unrelated aria-describedby tokens. Duplicate owned nodes for one field are cleared together so stale alerts and IDs cannot survive recovery. errors(form) returns current messages and focusFirstError(form) focuses the first invalid field.
  • setPending(form, bool) owns aria-busy and temporarily disables fields, restoring their previous disabled state afterward. It validates every control's native disabled value as bool before mutating the form. Live control/error collections and repeated field Lists are first copied with one bounded length snapshot; sparse/accessor Lists and changing collection lengths cannot alter the validated operation midway.
  • reset(form) restores pending/error ownership and then performs the native form reset.
  • Error nodes, attributes, text, focus, form/control properties, live DOM collections, the pending-state WeakMap, and native reset all use operations captured when the Forms module initializes. Branded browser objects are read only through native prototype contracts; an explicit data-only test host may provide enumerable own fields and methods, but accessor shadows are rejected without execution. Later JavaScript replacement cannot redirect an owned error, focus, pending restore, or reset operation.
  • Helpers require a real form element. Submission remains explicit through ordinary VelarScript event directives.
  • Event directives pass native browser events. Contextual KeyboardEvent, PointerEvent, InputEvent, CompositionEvent, and ClipboardEvent parameters expose their typed fields; zero-parameter handlers remain valid and no synthetic event runtime is added. Composition start/update/end provide CompositionEvent.data. Copy, cut, and paste provide ClipboardEvent; its raw DataTransfer stays hidden behind the bounded velar/browser text helpers described below. Framework-owned self, prevent, and stop modifier work uses captured native Event getters and methods, so an own accessor or instance override cannot run before the application handler.

velar/browser

import {after, blur, closeDialog, dialogResult, environment, every, focus, frame, scrollElementTo, scrollMetrics, setTextSelection, showDialog, textSelection, watchIntersection, watchOnline, watchVisibility} from "velar/browser"

component EnvironmentStatus:
    const stopReady = after(250ms, () => print("ready"))
    const stopHeartbeat = every(1s, () => print("heartbeat"))
    const stopOnline = watchOnline(online => print(online))
    const stopVisibility = watchVisibility(visible => print(visible))

    @cleanup:
        stopReady()
        stopHeartbeat()
        stopOnline()
        stopVisibility()

    return <p>{environment().online ? "online" : "offline"}</p>

after(duration, callback) schedules one callback and every(duration, callback) schedules repeated work. Each returns an idempotent () -> null stop function. Durations use Core ms or s values and must be non-negative; every requires a positive duration. Repeating work schedules its next turn only after the current synchronous or asynchronous callback settles, so slow polling cannot overlap itself. Stopping cannot abort a callback that has already started, but prevents every later turn. Callback failures are normalized through velar/app with phase timer and detail after or every. Timer and microtask functions are captured at module initialization, so later ambient replacement cannot steal scheduling or cancellation.

Timer handles are explicit component resources: start them during component setup or @mounted, retain the returned stop function, and release it from the sibling @cleanup block. VelarScript does not expose setTimeout, setInterval, or a React-style effect API.

  • location() and environment() return typed snapshots rather than exposing mutable browser globals. Host strings and booleans must already have the declared type; malformed values are rejected rather than implicitly converted. Location, Navigator, Document, MediaQueryList, DOMRect, and dialog values are read through captured native prototype getters; own accessors and instance shadows are ignored rather than executed.
  • Snapshot language lists are application-owned mutable copies limited to 1,000 entries of at most 256 characters and cannot contain sparse or accessor elements; the containing environment record remains read-only. Navigator, online, visibility, media-preference, and touch fields are each snapshotted once and validated before the record is returned. Layout rectangles use the native element operation and must contain finite numbers; animation-frame timestamps and dialog results remain bounded typed values before they cross back into VelarScript source.
  • media, watchMedia, watchOnline, and watchVisibility expose common environment state. Every watcher returns a cleanup function, and callback failures are owned by the application error channel. Media change metadata is read through the captured native browser getter or a data-only test-host field; a synthetic accessor event is rejected without invoking the getter. matchMedia and EventTarget add/remove operations are captured as one host ABI, so watcher cleanup remains paired even after ambient or instance poisoning.
  • watchIntersection(element, callback, options={}) is the fourth watcher and the only one whose subject is an element rather than the environment: it reports when that element enters or leaves the viewport, or a scroll container named by options.root. It is the checked shape for infinite scroll, lazy loading, and exposure logging. An entry carries {intersecting, ratio} and nothing more — a rectangle from an observer entry describes the moment the observation was taken rather than now, so measure remains the way to ask where an element is. options.thresholds is a List<number> of 1 to 32 ratios from 0 through 1, defaulting to [0.0]; there is no rootMargin, because it is a CSS-string dialect the host parses and a checked surface does not take one. The observer delivers a first entry when it starts watching, with no scroll required, which is the difference that matters against asking scrollMetrics inside on:scroll — a handler that never fires reports nothing, and an element that is already in view never fires one. A single delivery may cover several crossings; the callback receives the newest, which is the only one that still describes the element. Like the others it returns a () -> null cleanup, and IntersectionObserver construction, observe, disconnect, and both entry getters are captured when the module initializes.
  • readClipboardText() and writeClipboardText(text) are the system clipboard, and they are the whole of it: a copy button calls writeClipboardText. Both require a secure context and may reject when browser permission or user-gesture policy denies access — a write is reliably permitted from inside a user gesture such as a click handler, and a read prompts or is refused outright depending on the engine, so a paste path should prefer the on:paste event boundary below. Each operation snapshots the secure-context and native clipboard host once, then uses the captured platform method rather than a replaceable instance method.
  • clipboardText(event) and setClipboardText(event,text) are the synchronous copy/cut/paste event boundary. They require a native ClipboardEvent, use only text/plain, cap text at 16 MiB, and call captured DataTransfer operations. Raw clipboard formats, files, and DataTransfer mutation do not enter source. The handler remains responsible for calling event.preventDefault() when it replaces the browser's default copy, cut, or paste behavior.
  • open, scrollTo, scrollIntoView, scrollMetrics, scrollElementTo, measure, and frame cover intentional window, element, layout, and animation-frame operations. Text/URL/query inputs are strings, scroll coordinates are finite numbers, and behavior is exactly auto, smooth, or instant; invalid values fail before invoking the browser capability. scrollMetrics(element) returns {x,y,viewportWidth,viewportHeight,contentWidth,contentHeight} from captured native getters, and scrollElementTo moves that exact element without exposing mutable scrollTop/scrollLeft fields. Element scrolling and measurement call the validated platform prototype rather than an instance override. Every one of these reads answers the layout the browser has already performed, so a read that follows a change in the same turn — inside a watch, or straight after scrollElementTo — answers the layout from before it. await frame() first; "Watch forms and lifetime" above has the pattern and the reason.
  • capturePointer(element,pointerId) and releasePointer(element,pointerId) keep drag/select ownership on one native Element. IDs are bounded non-negative integers and the captured prototype operations retain native active-pointer errors; pointer capture state itself is not duplicated in VelarScript runtime state.
  • focus(element, preventScroll=false) and blur(element) provide explicit accessibility focus ownership for typed JSX refs. They require a real HTML element and invoke the native prototype operation rather than an instance override; preventScroll must be an actual boolean. Components remain responsible for choosing when focus should move or return.
  • <dialog ref={value}> narrows the mutable ref to DialogElement? (or the general Element?). showDialog requires a mounted native dialog and opens it modally; closeDialog(dialog, result="") closes an open dialog, and dialogResult returns its native string result. These helpers validate the browser object without exposing document construction or an untyped DOM. Dialog state is read once per operation and opening/closing uses the captured native prototype methods, so an instance override cannot replace framework behavior.
  • <textarea ref={value}> narrows the mutable ref to TextAreaElement?, a subtype of InputElement. textSelection(area) returns {start,end,direction} and setTextSelection(area,start,end,direction="none") updates it. Start/end are Unicode code-point offsets, never DOM UTF-16 units; a native selection that splits a surrogate pair is rejected. Ranges must be ordered and in bounds, and direction is none, forward, or backward. Textarea identity, value/selection getters, and setSelectionRange are captured when velar/browser initializes, so later prototype or instance replacement cannot redirect an editor transaction.

velar/files

import {download, pick, readText} from "velar/files"

const selected = await pick({accept: ".json", multiple: false})
if selected.size > 0:
    const source = await readText(selected[0])
    download("copy.json", source, "application/json")

pick uses the native file-input path supported by Chromium, Firefox, and WebKit. It returns List<File>, where opaque File values expose read-only name, size, type, and modified metadata; only values returned by pick may be read with readText or readDataUrl or attached to velar/http.formBody. Picker options are a data-only accept string/multiple bool record. download accepts actual text for its name, data, and MIME type. Invalid options, forged file records, and invalid download values fail before an input, reader, Blob, or object URL is created. readText(file, maxBytes=16777216) and readDataUrl(file, maxBytes=16777216) inspect native file size before reading; the explicit ceiling is 64 MiB. One picker result is limited to 10,000 files, and text downloads are likewise limited to 64 MiB. Directory access, persistent file handles, and the File System Access API are deliberately not part of Web API 0.14.

Returned file names/MIME types, sizes, and modification times are validated before an opaque File is registered. Invalid native picker results reject the Promise instead of escaping an event callback or leaving it pending. Missing or malformed native file lists are failures, not implicit empty selections. Picker results snapshot the native FileList length once and use captured platform prototype operations rather than indexed properties, custom iterators, or instance overrides. File metadata and text reads use the native File/Blob brands and likewise cannot invoke own getters or a replaced instance text method. readText and readDataUrl also verify the asynchronous reader result and its maximum encoded expansion before returning a string.

velar/worker and velar/websocket

Web projects use the same checked velar/worker contract as Node. The workers map in velar.json names source entries; production builds emit those graphs and the runtime resolves them without exposing bundle URLs. worker and workerPool validate requests and responses with runtime Types, bound queued calls, propagate per-call cancellation and timeout, transfer Bytes, and reject pending work if the native Worker crashes. A Worker entry uses serveWorker.

WorkerPool.broadcast(request, cancellation?, timeout?) sends one checked call to every live member and returns the checked replies in member-creation order. The pool preflights both its aggregate queue and every member queue, so a known capacity failure rejects before any member receives the broadcast. This is the pool-wide operation for initialization and immutable catalog installation; ordinary work continues to use least-loaded call.

A pool isolates a failed member. When a member's native Worker crashes, the runtime terminates that member, rejects its pending calls, and stops routing to it; later call requests go to the members still alive, and pool backpressure counts only their queued calls. A crashed member is not replaced — a pool's size is fixed for its lifetime — so a pool whose members have all crashed rejects further calls with WorkerCrashedError naming that condition rather than reporting the pool as closed.

A timeout is a real bound. When it elapses the call rejects with TimeoutError and gives its capacity slot back, whether or not the worker ever answers. The runtime also posts a cancellation to the worker, and serveWorker acknowledges it as the first thing it does on reaching the entry's message loop, so a worker whose message loop is still running is not mistaken for a wedged one: it is not terminated and keeps serving later calls. The acknowledgement attests to the worker, not to the call. serveWorker then cancels the handler's own Cancellation, so a handler that observes cancellation stops rather than running on. A worker that does not acknowledge within one second is no longer running that message loop; the runtime terminates it and rejects its remaining calls with WorkerCrashedError. The acknowledgement travels the same private channel as calls and replies and is not part of the source surface.

velar/websocket.connect returns an owned pull connection whose send accepts string | Bytes and whose next() resolves to the next checked message or null after close. Message size, unread message count, and pending send bytes are bounded. Binary messages set the native browser socket's array-buffer mode and enter source as Bytes; native Blob/ArrayBuffer/WebSocket objects remain hidden. Plain ws: is admitted by generated CSP only for loopback development; production origins use wss:.

send resolves when that message's own bytes have left the socket, not when the whole send buffer empties, so an early small message completes before a later large one rather than waiting behind it. A connection keeps a single drain watcher, shared by every send waiting on it; the watcher backs off while the socket makes no progress and stops once nothing is waiting. Closing or failing the connection settles every send still waiting: one whose bytes had already left resolves, and the rest reject with WebSocketClosedError.

A failed connection and a bounded one have named classes. connect rejects with WebSocketTimeoutError when the handshake has not completed within timeout, and with WebSocketProtocolError when the browser reports a transport error before the socket opens; an open connection whose socket errors rejects the waiting next() with the same class. send rejects with WebSocketBackpressureError when the message would push pending send bytes past maxPendingSendBytes — and next() rejects with it when a second read tries to wait while one already is, because a pull connection has one reader. A message larger than maxMessageBytes is a RangeError, and everything still waiting when the connection closes is a WebSocketClosedError.

velar/websocket is the only raw WebSocket client. D90 R20 retired velar/realtime.socket, a second, text-only client that admitted only close code 1000 and application codes 3000–4999, reported failure as a bare Error, and could not carry binary messages. velar/realtime does not expose a second raw socket; its application client composes velar/websocket.

velar/realtime

For bidirectional application protocols, use one shared command/event package on both sides and give its codec to realtimeClient:

import {Bytes} from "velar/binary"
import {RealtimeClient, RealtimeClientFailureAction, RealtimeOpen, realtimeClient} from "velar/realtime"

type ServerEvent:
    event: string

type Command:
    operation: string

def decode(message: string | Bytes) -> ServerEvent:
    if message is string: return Json.parse(message, ServerEvent)
    throw Error("Binary events are not supported")

def encode(command: Command) -> string | Bytes: return Json.stringify(command)

async def opened(client: RealtimeClient<Command>, open: RealtimeOpen):
    // generation 每次成功连接都会递增;重连后在这里重新订阅或请求快照。
    if open.reconnected: await client.send({operation: "resync"})

const live = realtimeClient(
    () => "wss://example.test/live",
    {decode, encode},
    async (event, _client) => print(event.event),
    opened=opened,
    failed=async (_failure, _client) => RealtimeClientFailureAction.reconnect,
    options={reconnectDelays: [0ms, 1s, 2s, 5s], reconnectJitter: 0.2},
)

await live.start()
await live.send({operation: "subscribe"})
await live.close()

The {decode, encode} pair is a RealtimeCodec<Incoming, Outgoing>: decode reads one string | Bytes frame into the incoming event type, and encode writes an outgoing command back to a frame. It is the one place the protocol's wire form is written, and both sides of the connection use the same one.

start() performs the first connection, whenOpen() waits for the current or next connection generation, and whenClosed() waits for terminal shutdown. The URL may be text or a zero-argument function, allowing a fresh signed URL on each attempt. Reconnect delays are finite; once exhausted, the client closes. retryInitial is false by default so configuration and authentication failures do not create an invisible infinite loop.

send requires an open connection and rejects with RealtimeUnavailableError otherwise. Commands are never buffered across a disconnect and never replayed after reconnect. Applications that need stronger delivery add message IDs, acknowledgements, resume cursors, and idempotent handling to their shared protocol. opened(..., RealtimeOpen) is the one place to renew subscriptions or fetch a fresh snapshot after a reconnect.

The failure callback chooses continue, reconnect, or stop. continue is meaningful for a rejected inbound message or handler; terminal transport failure still follows close-code/reconnect policy. state() answers the RealtimeClientState enum: idle, connecting, open, reconnecting, and closed.

For one-way server events, eventStream remains smaller:

import {eventStream} from "velar/realtime"

component LiveStatus:
    const updates = eventStream("/api/updates", {message: (text, id) => print(text)})

    @cleanup:
        updates.close()

    return <p>{updates.state()}</p>
  • eventStream wraps server-sent events and exposes url, state, and close, with optional credentials. Event streams report connecting, open, or closed. Unknown native state numbers fail rather than being mislabeled. Each state observation reads the native value once, so validation and the returned label cannot describe different states.
  • URLs are strings, EventSource credentials are bool, and handler records accept only documented enumerable callable data fields. Accessors and unknown fields fail before constructing the native connection. Handler throws and rejected promises are reported through velar/app with a stable realtime phase/detail instead of escaping the native event loop.
  • Realtime URLs are limited to 2 MiB and event text to 16 MiB. An oversized server-sent event closes its stream after reporting the boundary error, and event IDs are limited to 64 KiB on the same checked return path.
  • Resolved native connection URLs are validated again before becoming public fields. Inbound message text and event IDs are each snapshotted once through captured native getters before the typed callback runs; malformed or accessor-backed host metadata is reported through velar/app without implicit conversion or getter execution. Constructors, listeners, state getters, and close operations likewise use the host ABI captured when the module initializes rather than replaceable instance methods.
  • Event streams are ordinary owned resources. Applications close them explicitly from sibling component @cleanup; the API does not introduce React-style effects or hidden lifecycle behavior.

Core velar/test and Web velar/web-test

expect(value) provides typed toBe, toEqual, toBeTruthy, toBeFalsy, toContain, toMatch, toHaveLength, toThrow, and toReject matchers. toEqual is the prelude equals(a, b) — one implementation, not a matching one, so an assertion can never answer differently from the language it is testing. It therefore invents no test-only structural model, invokes no record getter, accepts no sparse List, treats no distinct class instance as equal, and refuses cyclic or over-deep data outright instead of quietly answering false. toBe follows VelarScript == value/reference semantics. The truthy/falsy spellings accept only actual booleans; they do not reintroduce JS truthiness. Specialized matchers are exposed only for compatible checked subjects, then validate the same rule at dynamic boundaries. In particular, toThrow requires a function, and toReject requires a Promise (or a function returning one); a synchronous throw cannot masquerade as a rejected Promise. Failure rendering is bounded for large/deep Lists, Maps, Sets, records, and strings. It never calls conversion hooks on functions or unknown objects, and invalid regular-expression construction uses a stable owned message rather than formatting a hostile thrown value. .test.vel files and test "name": discovery remain owned by velar test.

Browser application tests use a separate .browser.test.vel suffix and run only through velar test --browser. Because that command is the only place velar/web-test has a runtime, importing it from any other module — an application module, or a plain .test.vel — is refused at the import line rather than left to fail at the call:

import {expect} from "velar/test"
import {browser} from "velar/web-test"

test "the home page renders at the root route":
    await browser.open("/")
    await browser.waitForText("h1", "My VelarScript App")
    expect(await browser.currentPath()).toBe("/")

The browser controller intentionally exposes a compact automation surface: open, reload, click, fill, select, press, scroll, text, attribute, box, style, namespace, count, visible, waitFor, waitForText, currentPath, viewport, timings, animation, measureClick, measureFill, and measurePress. box(selector) reads the matched element's {x,y,width,height,top,right,bottom,left} rectangle, and style(selector, property) reads one computed CSS property as text. Both are read-only controls; neither opens arbitrary JavaScript evaluation.

Geometry and computed-style checks should assert the cause of a layout, not a coincidental pixel arrangement. Asserting a design token such as computed gap == "16px" is stable evidence; asserting that two unrelated blocks happen to sit 37 pixels apart couples the test to the rendering engine and makes an ordinary design change look like a regression. animation(selector) answers {name, rotating} for the element's running animation, so a keyframes declaration can be asserted without reading computed style. It is not a DOM or Playwright escape hatch. The CLI builds a real CSP production site, starts an isolated local host, creates a fresh browser context for each test, and automatically fails on page errors or error/warning console messages. A .browser.test.vel body runs in the test process, not in the page, so mount, JSX, document, and velar/storage are unavailable there; a call that reaches for them reports on the worker's host error channel and fails the test through the same trust rule, which also covers a detached task the test leaves running. Chromium is the local default; Chromium, Firefox, WebKit, or all three may be selected explicitly. Each engine runs against its own compiled copy of the test files, so a module of the project that a test file imports starts fresh for Chromium, Firefox, and WebKit alike: a difference between two engines in project state is a difference between the engines, not state one engine's pass left behind for the next. The velar/* standard modules are not copied per engine. They are written once, one directory above the per-engine trees, and all three engines resolve the same files, so standard-module state a test changes — setLevel on velar/log, for instance — is carried into the engines that run after it. A test that changes it restores it itself. namespace(selector) requires one matched node and returns its platform namespace URI so SVG/HTML lowering can be asserted without arbitrary page evaluation. Browser binaries remain an explicit Playwright install.

Because the body runs in the test process, a project module the test file imports is a second instance of that module. The page loaded its copy when the browser evaluated the built bundle; the test process evaluates its own copy in itself, and the two share nothing. A module-level state cell the test writes therefore moves the test's copy and not the page's — calling setLocale(Locale.enUS) from a browser test leaves the page rendering exactly what it rendered before, and no assertion about the page will show the change. What reaches the page is the browser controller; what a project import is for is work the test can call and judge on its own, such as a message table or a formatter. Anything whose effect has to land in the page is driven through a control the page itself offers, and covered for the module's own sake by a .test.vel unit test.

The CLI executes the whole browser-test run in a dedicated supervised worker. One test is bounded to 120 seconds, the aggregate run to 20 minutes, and each browser/context cleanup to 10 seconds. Browser engines are explicit BrowserServer owners rather than opaque launches: cleanup closes the connection, closes the server, escalates to its kill operation, and finally terminates the dedicated process group on POSIX hosts. SIGHUP, SIGINT, and SIGTERM propagate to the worker and keep exit codes 129, 130, and 143; an IPC parent disconnect also starts cleanup. Therefore a timed-out VelarScript Promise, a stuck browser close, or a killed invoking CLI cannot retain a headless renderer after the supervised run exits.

scroll(selector,x,y) performs one bounded element scroll with finite coordinates, allowing virtualized products to verify viewport transitions without exposing page evaluation. Its test-only page owner captures native Element identity and scrollTo before application code, so product prototype changes cannot redirect the automation seam.

timings() returns the current navigation's {firstContentfulPaintMs?,domContentLoadedMs,loadMs} snapshot. FCP is null when the browser supplies no paint entry. The three measure* methods perform the corresponding real automation action and return {inputDelayMs,processingDurationMs,nextFrameMs} for its click or input event. Text measureFill/measurePress accepts the first matching beforeinput or input event, so a controlled editor that prevents native mutation in beforeinput has the same measurement contract as an uncontrolled field. Processing duration ends after synchronous event dispatch; nextFrameMs is the end-to-end UI publication metric and therefore also covers queued framework DOM work and rendering. These records are finite, bounded, fail-closed values created by a test-only page runtime installed before application code. They do not expose a clock callback, page evaluation, production global, or arbitrary event injection to VelarScript source.

localStorage and sessionStorage from velar/web-test expose raw string get, set, remove, and clear operations after browser.open. They exist so recovery paths can plant malformed or legacy storage bytes without shipping a query parameter, debug button, or script escape in the product. Each browser test already owns a fresh context, so these controls cannot leak state between tests and are unavailable outside velar test --browser.

network.respond(path, body, status=200, contentType="text/plain; charset=utf-8", delayMs=0) installs a bounded response for one application-relative path, and network.clear() removes the test's routes. This lets a real browser test drive HTTP and streaming UI behavior without shipping a fake-provider branch in the application. Routes are confined to the isolated test origin and remain unavailable outside velar test --browser.

Deliberate boundaries

Web API 0.14 does not define SSR/server execution, service workers/PWA, WebRTC, WebGPU, directory handles, persistent file handles, or a game runtime. CanvasElement.getContext(kind=...) therefore returns unknown rather than an untyped browser escape hatch; the future game package will own a checked Canvas surface. JavaScript packages remain available through checked declarations or an explicit unsafe boundary when an application needs capabilities outside the official surface. velar/game remains a later Canvas-oriented package built on this Web platform.

Compatibility authority

The versioned type interface, emitted runtime source, and framework-host implementation live together in @velarscript/web, under packages/web. The CLI dynamically loads the project-declared /compiler and optional /host entries. Web owns HTML/CSP/reload/deployment projection and browser-test metadata; CLI owns generic routing, filesystem, bundling, transport, verification, and browser-driver mechanics. standardModuleApi() reports Web API 0.14 under the extension ID, and compiler tests protect exact names and types, and the Chromium, Firefox, and WebKit development/production matrix protects runtime behavior. Workbench does not copy these rules; completion and diagnostics arrive through the project's installed velar lsp.