⬡ FRAMEWERK
Core

State and expressions

Each screen declares stateVars as a string map; showWhen/hideWhen take a small expression language over that state, $env and $hover (the pointer inside the nearest hoverGroup).

View as Markdown

State and expressions

State is a string map. Each screen declares stateVars; every value is a string — "true", "42", "home" — and comparisons coerce as needed, which keeps specs JSON-serializable and free of type negotiation between author, validator and renderer.

Examples

A boolean that gates a badge:

Conditional visibility
phone
SPEC.JSON
RENDERED OUTPUTFRAMEWERK RENDERER
Coffee Cart
Toggle oat milk
Edit the JSON to update the rendered screen.Edits stay in this preview

An override keyed on the node's own width:

Container-relative override
phone
SPEC.JSON
RENDERED OUTPUTFRAMEWERK RENDERER
Card
Edit the JSON to update the rendered screen.Edits stay in this preview

Anatomy

stateVars

{
  "oatMilk": { "type": "boolean", "default": "false", "label": "Oat milk" },
  "tab":     { "type": "string",  "default": "home" }
}

type is "string", "boolean", or "number". default is always a string, whatever the type — "false", not false. The renderer seeds interactive state from these defaults.

Expressions

showWhen and hideWhen take an expression evaluated against current state:

{ "type": "badge", "content": "Oat milk", "showWhen": "$oatMilk == true" }

Supported: ==, !=, >, >=, <, <=, in(), not in(), &&, ||, !, and parentheses. State values are strings; numeric comparisons coerce to number.

The evaluator normalizes a few forgivable mistakes before parsing — a single = becomes ==, and quotes around single-word values are stripped — because these specs are frequently machine-authored and a silently-inert condition is worse than a lenient parse.

A variable reference must carry its sigil: $name for a state variable, {name} for a component prop. A bare word is a string literal, so hasItems == true compares the text "hasItems" — it does not read your state. There is also no way to compare against an empty string; spell "non-empty" as bare truthiness ($icon) rather than $icon != .

Both mistakes fail open — the node renders — because a node that silently vanishes is harder to diagnose than one that silently appears. The safety net is validation, not the render: each produces a warning naming the node and quoting the expression (unprefixed-variable, unparseable-expression).

$hover is true while the pointer is inside the nearest node marked hoverGroup (true, or a name) around or on the node that reads it. A card that shows its actions on hover needs no state variable:

{ "type": "column", "hoverGroup": true, "children": [
  { "type": "row", "showWhen": "$hover", "children": [ … ] } ] }

It listens to the pointer entering and leaving only, never its moves, and only in a played prototype. A capture pins it with $forceState (hover for everything, or <node id>:hover). A $hover read with no hoverGroup around it is never true and is a validation warning. $hover is built in; a state variable named hovered is a different thing.

Environment variables let one spec adapt across devices without duplicating screens:

{ "type": "row", "showWhen": "$env.device == desktop" }

Available: $env.device, $env.platform, $env.formFactor, $env.orientation, $env.category, $env.deviceClass, $env.breakpoint, $env.colorMode. Comparing $env.device against mobile, tablet, or desktop matches the preset's class, so $env.device == desktop is true on web-lg without naming the preset. $env.breakpoint is the viewport band (xs < 480, sm < 768, md < 1024, lg < 1440, xl) and ordered comparisons rank on that scale.

Container-relative conditions. $env answers "how wide is the screen"; $self.width and $self.height answer "how wide am I", for a component that is 3-up on desktop and 1-up in a sidebar at the same breakpoint. They are readable in showWhen, hideWhen and overrides[].when on the node that declares them; the node needs an _id; only nodes that ask are measured, and a node re-renders only when one of its own thresholds is crossed. The authored layout renders until a real size exists. An override on $self.width that also sets width would oscillate and is a validation warning.

Scroll variables make scroll-driven behavior expressible: the screen container publishes $env.scrollY (integer px scrolled) and $env.scrollProgress (a 0..1 fraction of the scrollable range — 0.5 is halfway; comparing it to 50 earns a validation warning). Both are 0 at rest.

{ "type": "column", "showWhen": "$env.scrollProgress > 0.5", "animate": "slide-up" }

gives reveal-on-scroll, and an override on $env.scrollY > 40 plus a transition gives sticky-header elevation. Re-renders happen only when a compared threshold is crossed, and progress is anchored to the base page height so a revealed node's own height cannot un-trigger it.

App state

A screen's stateVars belong to that screen: a host that navigates starts the next screen from its own defaults. That is right for a tab or an open sheet and wrong for the few values a prototype has to carry across screens — what went into the cart, the pickup slot.

Those are declared once, on the project, in the same shape:

{
  "project": {
    "stateVars": {
      "lastAdded": { "type": "string", "default": "flat-white" },
      "cartCount": { "type": "number", "default": "0" }
    }
  }
}

They are read as $app.<name> in an expression and {$app.<name>} in words, and written by the existing verbs with the namespace in the key:

{ "type": "button", "content": "Add to order", "action": "setState(app.lastAdded, cappuccino) | navigate(menu)" }
{ "type": "text", "content": "{$app.lastAdded} added", "showWhen": "$app.cartCount > 0" }
{ "type": "component", "ref": "site-header", "props": { "cartCount": "{$app.cartCount}" } }

{$app.<name>} resolves in content, label, placeholder, src, viewTransitionName, and in a component instance's prop value — so one instance shows every value, rather than one instance per value behind showWhen.

One flat map. An app value lives in the same string map as everything else, under the key app.<name>, exactly as a pinned env.scrollY does. There is no second store to pass around, and anything that can pin screen state can pin app state by naming that key. From weakest to strongest:

LayerSource
Declared defaultproject.stateVars.<name>.default
Personapersona.initialState["app.<name>"]
Live valuea setState(app.<name>, …) during the session
Pinan explicit stateValues entry

App state must be declared. A press that writes an undeclared screen variable creates it; a press that writes an undeclared app variable does not. Validation warns on a read or a write of a name project.stateVars does not declare, and an undeclared read renders as absence. The declaration is what lets a screenshot, a board and a reader of the spec agree on what a screen can be shown with.

When not to use it. Anything a single screen owns stays a screen variable. App state does not persist between sessions and no app value is computed from another; both are application logic, which the format does not model.

Stability

The expression grammar and $env / $self variable sets are part of the format contract. The app. key prefix and the layer order above are part of it too. Adding an operator or variable is additive; removing one, or changing how an existing expression evaluates, is breaking. sweepExpressions(spec) reports every suspect expression in a spec on its own, without the rest of the validation output.