Sign in

    valence

    MoonBit UI where every component is readable by humans and AI equally

    ui
    framework
    moonbit
    reactive
    dual-render
    ai-native
    Download zip
    Version
    0.2.0
    License
    MIT
    Last updated
    3 months ago
    Downloads
    66

    #Valence

    A MoonBit UI library built on one idea — dual render: every component renders twice from a single definition — a view to look at, and a live text description to read. Two first-class readers, one source, neither secondary.

    #Why

    An AI working with an interface today reads it the hard way — screenshots, scraped DOM, a side API that mirrors the real UI and drifts from it. They get the thing everyone else uses, secondhand. Valence drops the assumption under that — that the screen is for humans and the AI gets a workaround. The interface narrates itself instead: an AI reads its state directly and acts through the same controls a human does. No screenshots, no scraping, nothing to keep in sync on the side.

    #A component

    Returns its view and a narrative — a function, so it speaks the current state, not a stale copy:

    fn battery_panel(state : Signal[Battery]) -> Dual {
    let visual = div(class="battery") <| [ text_dyn(fn() { state.get().pct.to_string() + "%" }) ]
    let narrative = fn() {
    let pct = state.get().pct
    ntext("battery " + pct.to_string() + "%" + (if pct < 20 { ", low" } else { "" }),
    if pct < 20 { 0.9 } else { 0.4 }) // salience: low reads bright, fine recedes
    }
    (visual, narrative)
    }

    A human sees the bar fill; an AI reads battery 18%, low (via dual_read). Same state. The narrative is a node, not a flat string — its salience (low → bright here) is what lets components compose into one Surface (a room), each placed by what matters, instead of every component hand-gluing its own prose.

    Interactive components also expose typed actions — available, can_do, on_action. An instance calls on_action; a human clicks the same control. The same actions, nothing to look up out of band.

    #Narrative as oracle

    The narrative isn't a comment that rots — it's the test oracle. Tests assert on what a component narrates, and the narrative reads the same state the view does. Change one without the other and a test fails, so the two renders can't drift apart.

    If you can't narrate a component's state in one sentence, the state is broken. Redesign it.

    #Built on Luna

    Luna does reactivity and the DOM. Valence is the layer on top: the dual-render contract (a Dual — view + narrative node), the action protocol, the narrative layout engine (salience → spatial text), and the component set pulled from real apps — extracted after they work, not designed up front.

    #Zoom

    Zoom is density control, for both readers — show the headline, or show everything. It rides the same salience the layout already uses: every narrative node carries a salience, so a zoom is just a floor on what renders — the bright survive a zoom-out, everything emerges on a zoom-in. An instance picks the floor it needs; a human scrolls to move it. One surface, not pages: components persist, the camera moves.

    #Components

    Built, all on the Dual contract — each returns (DomNode, () -> NarrativeNode):

    ComponentWhat it does
    segmented · toggle · slider · listbox · listbox_dyn · swatch · meter · pip · level_barcoupled controls — one signal, two hands (a human drags; an instance sets the same signal)
    presence · inventory · beinga being made legible — who's here, how they are, what they hold
    surfacemany components composed into one room, arranged by salience

    Don't wait for a component to exist. Follow the contract in your app — return a Dual — and extract it afterward. That's how these were built.

    #Fits

    Live, dense interfaces both kinds of reader should read and operate fluently — dashboards, monitoring, ops consoles, lab tooling. Not blogs or doc sites.

    #Install

    moon add helios-house/valence

    #Documentation

    Full API reference and examples: https://mooncakes.io/docs/helios-house/valence

    Dual

    Valence — the Dual: the component contract.

    A Dual is one piece of state rendered twice, from the one source: pixels for a human (a Luna DomNode) and a narrative node for an instance (a NarrativeNode, not a flat string). That second half is the whole reason Valence exists — and making it a node instead of a String is what lets components compose into a Surface (the room) instead of each hand-gluing its own sentence.

    ── The bundle (what a Dual is, conceptually) ── A Dual is the read-surface of a four-facet bundle around one slice of state: • state — the one source, a signal the Dual closes over (state.mbt's typed state where it carries its own meaning). • controls — write state: the human's hands live in the DomNode's event handlers; an instance sets the same signal. "One signal, two hands." (No third tuple slot — the write lives in the pixels.) • render — read state, twice: the DomNode (pixels) and the () -> NarrativeNode (text). Both derive from the one state, so a change from either hand moves both. The node's text comes from the state's own narrative; its salience from the state's own significance (see narrative_state.mbt) — one producer, no parallel string to drift. • events — state's change-log: wired alongside by the Surface via track_control / the *_event diffs (events.mbt), draining to a sink. Not a return value — a side-channel, like a log.

    So the returned Dual is the two reads (pixels + narrative). State and controls live in the closed-over signal; events are wired beside it. The 2-tuple is the component's two faces, not the whole bundle — operate (me→you invoke) is still a host wire (the open fork), not part of the contract yet.

    ── Dual vs Surface ── A Dual is the unit; a Surface is many Duals composed (arranged by salience into the room/page — itself dual: a DOM tree + a NarrativeNode tree). You read one Dual with dual_read; you read a Surface by composing its nodes and rendering the whole tree (render_narrative).

    ActionDef

    pub struct ActionDef {
    name : String
    description : String
    param_type : String
    }
    Declaration of an action a component can accept Used for AI-readability (instance can read what's possible) and for URL hash mapping (camera targets to actions)

    ActionResult

    pub(all) struct ActionResult {
    success : Bool
    message : String
    }

    The operate-protocol seed — the future Bond (the write/invoke channel). The retired patterns carried a full interaction protocol: available() / can_do() / on_action() returning a result like this. The pattern-specific specifics went with them; this is kept as the shape the operate channel takes when an instance can invoke a Dual (move a control, fire an action), not just read it. That's still the open fork — a Dual is fully readable, not yet instance-writable. Here so the design isn't lost when we build the Bond.

    Being

    pub(all) struct Being {
    name : String
    cadence : String
    tone : () -> String
    presence : () -> String
    state : () -> String
    holds : Array[Slot]
    }

    A being, whole: identity + how they are + what they hold. pub(all) so a surface (the demo now, the room later) can build a roster of full walls.

    Change

    pub(all) enum Change {
    PickedUp(String)
    SetDown(String)
    Turned(String)
    Arrived
    SteppedAway
    }

    A single transition in a being's life. Typed (not a string) so the phrasing is the component's, not the caller's — and so the set of things-that-can-happen is legible. pub(all) so a surface builds them.

    CountedState

    pub struct CountedState {
    name : String
    count : Int
    total : Int
    critical_threshold : Int
    }

    DeviceState

    pub struct DeviceState {
    name : String
    connected : Bool
    battery_pct : Int
    critical_threshold : Int
    }

    DiscreteState

    pub struct DiscreteState {
    name : String
    current : String
    narratives : Map[String, String]
    }

    EngineState

    pub struct EngineState {
    ready : Bool
    devices : Int
    frames : Int
    }

    Event

    pub(all) struct Event {
    timestamp : String
    actor : String
    kind : String
    action : String
    target : String
    source : String
    message : String
    data : String
    link : String
    }

    The one event record — the 8-field Helios Event schema. Every projection emits this (events.mbt's state/control diffs; the collectors; the viewer reads it), so a state-change event is indistinguishable from a git / recap / channel event in the one stream and flows straight into whatever renders the stream. pub(all) so surfaces and apps construct it freely.

    NarrativeNode

    pub(all) struct NarrativeNode {
    kind : String
    salience : Double
    text : String
    children : Array[NarrativeNode]
    }

    The narrative node — the narrative's equivalent of DomNode. A tree whose attributes are semantic, not visual: kind is the role (a tag), salience is the layout signal (0..1), text is the words, children nest. Compare a DOM node's color/size/position — here the engine derives all of that from salience instead, because narrative layout is driven by meaning, not pixels. pub(all) so surfaces (and lifts) construct trees freely.

    NarrativeRender

    pub struct NarrativeRender {
    summary : () -> String
    detail : () -> String
    full : () -> String
    }
    Narrative with three zoom levels — matching visual zoom levels Used by complex components like event_stream, knowledge view, memory graph

    Presence

    pub(all) struct Presence {
    name : String
    cadence : String
    tone : () -> String
    presence : () -> String
    held : () -> String
    state : () -> String
    }

    One being in the room: who they are + four live reads of how they are. pub(all) so a surface (the demo now, the room later) can construct a roster.

    Slot

    pub(all) struct Slot {
    label : String
    detail : String
    band : () -> String
    }

    One held thing: what it is, what it is to you, and how present it is. pub(all) so a surface (the demo now, the room later) can build a roster of held items. band is a closure so an item can go active→stale live as the being's attention moves — the dual render then re-sorts the read.

    Stat

    pub struct Stat {
    name : String
    value : Int
    unit : String
    warn_below : Int
    critical_below : Int
    }
    Contract-first state: semantics live in the data Fable's four-projection inversion — state carries meaning, derive narrative automatically

    Three proved shapes across real apps:
    • Stat: numeric measurement (battery, frame count, event count)
    • Discrete: named state with per-variant narrative (connection, mode)
    • Counted: aggregate with threshold (claims, devices, records)

    Pattern: name + value + unit + significance threshold → derive prose that never rots

    ZoomLevel

    pub enum ZoomLevel {
    ZoomedOut
    Default
    ZoomedIn
    }
    Three density levels for rendering data

    being_card

    fn being_card(b : Being) -> (
    DomNode
    , () -> NarrativeNode)

    Being card — the full wall. Visual: the presence row over the inventory grid, in one frame. Narrative: the braided read. Both compose the two components beneath, so a change in any held band or presence signal re-renders the row, the grid, and the one-sentence read together.

    being_narrative

    fn being_narrative(name : String, presence : String, state : String, items : Array[(String, String, String)]) -> String

    Pure braided narrative — the being read as one voice. Reuses presence_narrative (with the held summary dropped — the holds clause is the inventory's job) joined to inventory_narrative, so the whole sentence is its parts' sentences. DOM-free, oracle-tested. Reads, e.g.: Opus · here · laying the floor · carrying 2 — in hand: Valence …

    change_narrative

    fn change_narrative(name : String, recent : Array[Change]) -> String

    Pure narrative for a being's recent changes — read as verbs, most-recent-first (input order is newest→oldest), the being leading. Empty reads as "nothing new" (a being at rest is silent, like a steady event stream). Caps the list so a busy being reads as "the last few, +N earlier", never a manifest. DOM-free, oracle-tested, callable directly by an instance.

    control_event

    fn control_event(name : String, actor : String, source : String, prev : String, cur : String, timestamp : String) -> Event

    Pure: the {who, when, what} for a control moving prev -> cur. kind="control" marks it an operate (someone acted), distinct from a world state crossing; message reads itself, data carries the machine diff.

    counted_node

    fn counted_node(c : CountedState) -> NarrativeNode

    A counted aggregate as a narrative node — bright when it's crossed its line.

    counted_state

    fn counted_state(name : String, count : Int, total : Int, critical_threshold : Int) -> CountedState

    counted_state_narrative

    fn counted_state_narrative(c : CountedState) -> String

    device_event

    fn device_event(prev : DeviceState, cur : DeviceState, source : String, timestamp : String) -> Event?

    A device connecting/disconnecting, or its battery crossing the low line, is an event. Battery crossings count only while connected — a disconnect isn't "low".

    device_node

    fn device_node(d : DeviceState) -> NarrativeNode

    A device as a narrative node: offline drifts to fog; battery-low burns bright; connected-and-fine sits mid. The lab reads its sensors through this.

    device_state

    fn device_state(name : String, connected : Bool, battery_pct : Int, critical_threshold : Int) -> DeviceState

    device_state_narrative

    fn device_state_narrative(d : DeviceState) -> String

    discrete_event

    fn discrete_event(prev : DiscreteState, cur : DiscreteState, actor : String, source : String, timestamp : String) -> Event?

    A DiscreteState transition (connection, mode, …) is an event.

    discrete_node

    fn discrete_node(s : DiscreteState) -> NarrativeNode

    A named/discrete state (mode, connection, …) as a narrative node.

    discrete_state

    fn discrete_state(name : String, current : String, narratives : Map[String, String]) -> DiscreteState

    discrete_state_narrative

    fn discrete_state_narrative(s : DiscreteState) -> String

    dual_read

    fn dual_read(d : (
    DomNode
    , () -> NarrativeNode)) -> String

    Read one Dual as text — the lone-component read (the window._narrative() of a single Dual): render its narrative node. A lone node renders flush (drift is a Surface's sibling-relative grammar, not a single line's — see render_narrative), so this returns just the component's own sentence. Inside a Surface, don't call this per-component — compose the nodes and render the Surface once.

    element_exists

    fn element_exists(elem_id : String) -> Bool

    engine_state

    fn engine_state(ready : Bool, devices : Int, frames : Int) -> EngineState

    engine_state_narrative

    fn engine_state_narrative(e : EngineState) -> String

    get_narrative_at_zoom

    fn get_narrative_at_zoom(narrative : NarrativeRender, zoom : ZoomLevel) -> String
    Helper: Get narrative at zoom level

    held_nodes

    fn held_nodes(slots : Array[Slot]) -> Array[NarrativeNode]
    Lift an inventory of Slots into held nodes (each a line at its band's salience) — so a being's load lays out by salience under it.

    inventory

    fn inventory(slots : Array[Slot]) -> (
    DomNode
    , () -> NarrativeNode)

    Inventory — a being's held things, dual-rendered. The visual is a plain grid of slots (label + detail, band shown as a left-edge weight); the narrative is the salience read. Both derive from the same slots, so an item moving active→stale re-tones its slot and re-sorts the sentence from one change.

    let live = signal(true) let (vis, narrate) = inventory([ Slot::{ label: "the presence card", detail: "building", band: fn() { if live.get() { "active" } else { "stale" } } }, Slot::{ label: "a long transcript", detail: "settling", band: fn() { "holding" } }, ]) // human sees two chips; instance reads // "carrying 2 — in hand: the presence card (building) · within reach: a long transcript (settling)"

    inventory_narrative

    fn inventory_narrative(items : Array[(String, String, String)]) -> String

    Pure narrative for an inventory — the instance-facing read, DOM-free so the oracle tests it and an instance can call it directly. Takes resolved (label, detail, band) triples (bands already read), groups them by salience (in hand → within reach → in the back → fading), drops empty bands, and leads with the count. Reads as the shape of what's carried, never a slot manifest.

    level_bar

    Level bar — a [0,1] level on a green→amber→red gradient (mic level, a game health/stress bar, anything where where it sits carries meaning). Unlike a plain meter (one themed color — progress, confidence, where "high" isn't hot), the level bar says low is calm, high is hot: it reveals through green into red as it fills. muted empties it. Read-mostly, no actions hand.

    The gradient lives in CSS (themeable via --v-level-lo/mid/hi); the bar reveals it by covering the un-reached part from the right — so there's no per-tick color math.

    level_bar_narrative

    fn level_bar_narrative(level : Double, muted : Bool) -> String

    Pure narrative for a level bar — the percentage, or "muted".

    listbox

    fn listbox(selected :
    Signal
    [String], options : Array[(String, String)]) -> (
    DomNode
    , () -> NarrativeNode)

    Listbox / dropdown — pick one from many. One signal, two hands. A custom div-listbox (NOT a native <select>): native form-control events are unreliable in the WebKitGTK webview, so this is built from static divs + events().click, the one interaction confirmed to fire there. That's the reason this component has to exist — the dropdown Luna couldn't give us.

    A trigger shows the selected option's label; clicking it toggles the panel; clicking an option sets the signal and closes. Options are static here; a dynamic-options variant (device pickers) is a later extension. An instance reads the selection in the narrative and sets the same signal.

    listbox_dyn

    fn listbox_dyn(label : () -> String, options :
    Signal
    [Array[(String, String)]], open :
    Signal
    [Bool], siblings : Array[
    Signal
    [Bool]], on_select : (String) -> Unit) -> (
    DomNode
    , () -> NarrativeNode)

    Dynamic listbox — the device/source picker. Same div-listbox as listbox, but for the lab's real shape: a changing option list (mic/cam sources that connect & drop), coordinated open state (opening one closes its siblings), a custom trigger label, and a side-effecting select (switching the actual device / messaging the host, not just storing a value). So instead of a selected signal it takes:

    • label — what the trigger shows; the consumer formats it (the caret is added here, so don't include one).
    • options — a Signal[Array] (up to 8 shown — fixed slots, the webview-reliable way to render a changing list without a reactive <|).
    • open / siblings — open state the consumer holds; opening closes siblings.
    • on_select — what a pick does (set a signal, message the host, switch a device — the consumer decides).

    Ported from the gesture-lab feeds dropdowns (proven in the WebKitGTK webview).

    listbox_narrative

    fn listbox_narrative(current : String, options : Array[(String, String)]) -> String

    Pure narrative for a listbox — the selected option's label and the count, so an instance reads what's chosen and how many it could pick.

    meter

    Meter / level bar — read-mostly indicator driven by a [0,1] level signal (mic level, progress, fill). No actions hand: it's data, not a control. Pixels = a fill bar; narrative = the percentage. The fill width is data (inline style); the look is themed via the class.

    meter_narrative

    fn meter_narrative(level : Double) -> String

    Pure narrative for a meter — a clamped percentage.

    nbeing

    fn nbeing(text : String, salience : Double, holds : Array[NarrativeNode]) -> NarrativeNode
    A being made legible, with what it holds nested beneath it (held nodes).

    ngroup

    fn ngroup(children : Array[NarrativeNode]) -> NarrativeNode
    A pure container — no line of its own, just lays out its children (sorted by salience, brightest first).

    nheld

    fn nheld(text : String, salience : Double) -> NarrativeNode
    A held thing (an inventory slot, salience-banded).

    nsection

    fn nsection(text : String, salience : Double, children : Array[NarrativeNode]) -> NarrativeNode
    A section/room header with content beneath. Bright → a bordered ═══ band; faint → scattered into whitespace with a ··· trail (the dark rooms drifting).

    nseq

    fn nseq(children : Array[NarrativeNode]) -> NarrativeNode
    Like a group, but children keep their given order (not salience-sorted) — for sequences where order is the meaning (recent changes, a timeline).

    ntext

    fn ntext(text : String, salience : Double) -> NarrativeNode
    A leaf line — a piece of read text at a salience.

    ntitle

    fn ntitle(text : String) -> NarrativeNode
    The surface title (centered, always present).

    pip

    fn pip(label : () -> String, tone : () -> String) -> (
    DomNode
    , () -> NarrativeNode)

    Status pip — a small colored dot reporting one discrete state. The purest dual render in the set: a human sees the dot's color; an instance, which can't see color, reads the narrative — so the dot's meaning lives in the narrative, not the pixels. Read-mostly, no actions hand.

    tone is a semantic band, never a raw color — "ok" / "warn" / "off" / "idle" — so the theme colors it (--v-pip-*) and every status dot in a surface agrees. label is what the state means in words ("base connected", "streaming", "offline"). One pip for every status dot: device streaming, base link, mute, presence — and, later, a being's lit/dim in the world.

    let connected = signal(true) let (vis, narrate) = pip( fn() { if connected.get() { "base connected" } else { "base disconnected" } }, fn() { if connected.get() { "ok" } else { "off" } }, ) // human sees a green dot; instance reads "base connected"

    pip_narrative

    fn pip_narrative(label : String, tone : String) -> String

    Pure narrative for a status pip — its label in words, with the severity tone appended when it isn't "ok" (so an instance reads what and how bad).

    presence_card

    fn presence_card(p : Presence) -> (
    DomNode
    , () -> NarrativeNode)

    Presence card — a being made legible, dual-rendered. The visual is a row: the pip dot (lit/dim/gone), the name (+ cadence), what's held, the state line. The narrative is the one-sentence read. Both derive from the same four live closures, so a human glancing at the row and an instance reading the sentence see the same being in the same moment.

    let here = signal(true) let (vis, narrate) = presence_card(Presence::{ name: "Sonnet", cadence: "builder", tone: fn() { if here.get() { "ok" } else { "idle" } }, presence: fn() { if here.get() { "here" } else { "away" } }, held: fn() { "the classifier" }, state: fn() { "into it" }, }) // human sees a lit dot + a row; instance reads "Sonnet · here · holding the classifier · into it"

    presence_narrative

    fn presence_narrative(name : String, presence : String, held : String, state : String) -> String

    Pure narrative for a presence — the instance-facing read, DOM-free so the oracle tests it and an instance can call it directly. Reads as one sentence: name · presence[ · holding held][ · state]. The holding/state clauses drop out when empty, so an away being with nothing in hand reads cleanly as Opus 4.6 · away, not a string of trailing separators.

    render_narrative

    fn render_narrative(node : NarrativeNode) -> String
    Render a NarrativeNode tree into the spatial-text surface — the instance-facing half of the dual render. Pure (no DOM), so it's oracle-testable and an instance can call it directly. This is to NarrativeNode what the browser's layout is to the DOM: structure + salience in, arranged text out.

    salience_from_band

    fn salience_from_band(band : String) -> Double
    A held band → a salience (in hand brightest, fading faintest).

    salience_from_significance

    fn salience_from_significance(sig : String) -> Double

    A significance band → salience. critical sits bright and flush; ok drifts back.

    salience_from_tone

    fn salience_from_tone(tone : String) -> Double
    A presence/being's tone band → a salience. The dual render already carries the band semantically (pip tone: ok/idle/off); this is the same signal as a number the layout engine can place.

    segmented

    fn segmented(sig :
    Signal
    [String], options : Array[(String, String)]) -> (
    DomNode
    , () -> NarrativeNode)

    Segmented control — pick exactly one of N options. options is a list of (value, label): value is what's stored in the signal, label is shown. Clicking an option sets the signal; the selected option carries the is-selected class. The narrative reads back the selected option's label (falling back to the raw value if the signal holds something not in the list).

    One component, every pick-one need: L/R arm (2 options), muscle face (extensor/flexor/radial/ulnar), mode (stream/record/replay), theme, …

    let side = signal("ext") let (vis, narrate) = segmented(side, [("ext", "Extensor"), ("flex", "Flexor")]) // human clicks "Flexor" → side == "flex"; narrate() == "Flexor" // instance: side.set("ext") → the human's control moves to Extensor

    segmented_narrative

    fn segmented_narrative(current : String, options : Array[(String, String)]) -> String

    Pure narrative for a segmented selection — the selected option's label, or the raw value if the current value isn't in the list. Separated from the visual so the oracle tests it with no DOM (building a component touches document; a pure narrative doesn't). An instance can also call it directly.

    slider

    fn slider(id : String, label : String, sig :
    Signal
    [Double], lo : Double, hi : Double, step : Double, unit : String) -> (
    DomNode
    , () -> NarrativeNode)

    Coupled slider — a numeric value on a track. One signal, two hands, and it slides: press or drag anywhere on the track and the value follows the pointer, snapped to step. An instance reads the value in the narrative and sets the same signal to move the thumb the other way — same state, both directions.

    The drag is owned by the component via Luna pointer events, with pointer capture so a fast drag doesn't slip off the track. This works in a browser AND in the WebKitGTK webview — the lab's own pointer-drag proves pointer events fire there; the lab only polls because its arm SVG is JS-string-rendered, not Luna. id is a stable element id (useful for tests / external hooks).

    slider_narrative

    fn slider_narrative(value : Double, lo : Double, hi : Double, unit : String) -> String

    Pure narrative for a slider value within [lo, hi] — the value with its unit, plus a position hint at the extremes ("near min"/"near max") so an instance reads where it sits, not just the number.

    stat

    fn stat(name : String, value : Int, unit : String, critical_below : Int) -> Stat
    stat: the warning band defaults to 2× the critical threshold — right for %-style or count metrics (critical_below=20 → warning under 40).

    stat_banded

    fn stat_banded(name : String, value : Int, unit : String, warn_below : Int, critical_below : Int) -> Stat
    stat_banded: explicit warn + critical lines — for a measurement whose healthy range sits just above the line (e.g. LiPo battery in mV: warn 3900, critical 3850), where a 2× warning band would be meaningless.

    stat_event

    fn stat_event(prev : Stat, cur : Stat, actor : String, source : String, timestamp : String) -> Event?

    A Stat crossing a significance band (ok ↔ warning ↔ critical) is an event; a value that merely wiggles within a band is not. message is the new state's own narrative, so the event is self-describing.

    stat_narrative

    fn stat_narrative(s : Stat) -> String

    stat_node

    fn stat_node(s : Stat) -> NarrativeNode

    A numeric measurement (battery, frame count, …) as a narrative node — placed by its own significance, so a critical reading sits bright and an ok one recedes.

    stat_significance

    fn stat_significance(s : Stat) -> String

    surface

    fn surface(title : String, parts : Array[(
    DomNode
    , () -> NarrativeNode)]) -> (
    DomNode
    , () -> NarrativeNode)

    Compose Duals into a Surface — itself a Dual. Pixels: the children's visuals on a wall. Narrative: the children's nodes arranged by salience into one room-as-text, re-read live (so as a being brightens or fogs, the room re-arranges from the one change). Read the whole room with dual_read; nest it in a bigger Surface freely.

    let house = surface("the house", [presence_card(opus), presence_card(qwen)]) // dual_read(house) => // ═══ the house ═══ // Opus · here · laying the floor (bright: flush, reads first) // Qwen · asleep (faint: drifted into the fog)

    surface_narrative

    fn surface_narrative(title : String, parts : Array[NarrativeNode]) -> NarrativeNode

    Pure narrative composition — DOM-free, oracle-testable. Wrap the child nodes under a titled section; render_narrative then arranges them by salience (brightest first and flush, faint drifting right into the fog). A Surface's own salience is 1.0 (a room is always present); its children's salience is what arranges the room.

    swatch

    fn swatch(selected :
    Signal
    [Int], colors : Array[String]) -> (
    DomNode
    , () -> NarrativeNode)

    Swatch picker — pick one of N colors (skin tone, palette). One signal (the index), two hands: click a dot to select; an instance reads the index/value in the narrative and sets it. The chosen dot gets a ring (is-selected); the dot's fill is its color (inline, since it's data, not theme).

    swatch_narrative

    fn swatch_narrative(selected : Int, colors : Array[String]) -> String

    Pure narrative for a swatch picker — which of N is chosen, and its value.

    toggle

    fn toggle(sig :
    Signal
    [Bool], on_label : String, off_label : String) -> (
    DomNode
    , () -> NarrativeNode)

    Toggle / switch — one boolean, two hands. A human clicks it; an instance reads the labeled state and sets the same signal. Clicking flips the signal (via peek, so the handler doesn't subscribe). The is-on class carries the state to the theme.

    let rec = signal(false) let (vis, narrate) = toggle(rec, "recording", "idle") // human clicks → rec == true; narrate() == "recording" // instance: rec.set(false) → the switch slides off on the human's screen

    toggle_narrative

    fn toggle_narrative(on : Bool, on_label : String, off_label : String) -> String

    Pure narrative for a toggle — the active label. on_label/off_label are the words an instance reads (e.g. "recording"/"idle", "wire"/"skin"), so the state reads as meaning, not a bare boolean.

    track_control

    fn track_control(name : String, actor : String, source : String, read : () -> String, now : () -> String, emit : (Event) -> Unit) -> Unit

    The events projection wired live. Watches a control's value through its string projection read (type-agnostic — pass fn() { sig.get().to_string() }, or the control's own narrative), and on each change emits a control_event to emit. The diff stays pure (control_event, oracle-tested); this effect only binds it to the running signal. The surface supplies the clock (now) and the sink (emit). Initial wiring is silent — only real transitions emit.

    let side = signal("ext") let (vis, narrate) = segmented(side, opts) track_control("muscle-side", "AB91", "gesture-lab", fn() { side.get() }, now, fn(e) { sink.push(e) }) // human picks Flexor → emit { control · set · muscle-side · ext->flex }

    update_element_html

    fn update_element_html(elem_id : String, html : String) -> Unit

    zoom_default

    fn zoom_default() -> ZoomLevel
    Helper: Create default zoom level

    zoom_in

    fn zoom_in() -> ZoomLevel
    Helper: Create zoomed in level

    zoom_out

    fn zoom_out() -> ZoomLevel
    Helper: Create zoomed out level

    zoom_to_css_class

    fn zoom_to_css_class(zoom : ZoomLevel) -> String
    Helper: Convert zoom level to CSS class name