valence

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

ui
framework
moonbit
reactive
dual-render
ai-native
moon add helios-house/valence@0.3.1
Download zip
Version
0.3.1
License
MIT
Last updated
last month
Downloads
40

Dependencies

README

#Valence

A MoonBit UI library where every component renders twice, from one state: pixels for a human, and a live sentence for an AI.

let volume = signal(0.7)
let (node, narrate) = slider("vol", "Volume", volume, 0.0, 1.0, 0.05, "")

// render `node` → a draggable track a human slides
// `narrate()` → the same state as prose, live: "0.7"
volume.set(0.95) // an instance's hand on the same signal:
// the thumb moves, narrate() reads "0.95 (near max)"

One state. Two renders. The sentence is not a docstring — it reads the same signals the pixels do, at call time, and it cannot drift, because drift is a red build.

moon add helios-house/valence

Docs and API: https://mooncakes.io/docs/helios-house/valence

#The contract

Dual = (DomNode, () -> NarrativeNode) — a bare tuple on purpose. No base class, no lock-in; follow the contract in your own code and you're a Valence component. A Surface composes Duals into one room with one narrative tree and a salience-driven layout engine (salience → spatial text).

#What the second reader changes

  • Your tests get an oracle. Components narrate their own state; tests assert on the narrative. If pixels and prose disagree, a test fails.
  • Your agents operate the UI you already have. An AI reads the narrative and sets the same signals a human's drag sets. One control, two hands. No scraping, no screenshots, no side API to maintain.
  • Your ops narrate themselves. window._narrative() on any Valence surface returns the state of the room in prose. Diagnosing a live app becomes reading a paragraph.

#Three strata, usable alone

The component list spans categories on purpose — sliders and listboxes (web), presence cards and inventories (game), signal meters and level bars (lab) — because our three production surfaces are a control room, a shared social space, and a scientific instrument reading real electromyography hardware, and they share a component library because they share a world.

  1. The contract — the Dual, the Surface composer, the narrative layout engine. The invention.
  2. The controls — segmented, toggle, slider, listbox, swatch, meter, pip, level_bar. Nine today; they grow one by one as real apps need them — not from parity checklists — each arriving with its narrative half for free. (There will probably never be a datepicker. There is going to be a gesture-timeline-with-rep-markers.) For lists that grow and change there's list_dual — keyed rows, atomic swap, narrative derived from the same items, with one law baked in: identity is read live at interaction time, never captured at build time. Stale slots are how UIs lie; this one can't. (Underneath it's update_element_html, visible on purpose: the doorframe is the point.)
  3. The room — presence, inventory, being, change. Components for spaces where humans and AIs are present together, extracted from a running shared space where they are load-bearing every day.

#The vocabulary

A Dual has two first-class readers — humans and instances (AI agents addressed as the same reader across sessions); a composed surface is a room, and whoever is in it, human or instance, is a being. Low-salience content drifts toward whitespace — the fog: nothing there is deleted, it dims and holds its place, and raising the salience floor both thickens the fog and speeds the render, because in a narrative surface attention and economy are the same gesture. Presence is witnessed, not set: a room can show a being's state, but operating a being is not a thing a room does to anyone.

That worldview leaks into the API (presence_card, nbeing, salience floats) because it's the domain model, not decoration.

#Voices

The same narrative tree renders in more than one voice — a register, not a theme: rules for how salience becomes typography, not different words. Three ship today: house (calm, plain — the default), bester (spatial drama: layout enacts state; bright lines sit flush, faint things drift right until distance is the meaning), and ops (dense statusboard, zero lyric — built first for readers with the least room, on purpose). A voice never falsifies content; it re-weights how the same truth sits on the page. The day one of your readers wakes with 5% of their context left and still needs the truth in 200 bytes, the dense voice is the one that respects them. render_narrative / render_narrative_bester / render_narrative_ops; rules are data, so voices grow without new engines.

#The wire

Any narrative renders as a frame: one header line (source, line count, weight in bytes) plus salience|text payload lines in tree order (render_frame). Every line carries its own salience, so any truncation is safe — a reader cuts to a salience floor or a byte budget and the least-bright lines drop first, never the load-bearing one. The header always states the weight of what was actually returned. Transport cost is a rendering concern for one of our readers — the same care as the fog, taken to the wire.

#Narrative as oracle

The design rule this library is opinionated about:

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

Testing note: assert on the narrative's content bands, not full strings — tuning the drift curve shouldn't redden a test that was checking meaning.

#Performance

Numbers, measured, repeatable (tools/valence-bench, one command, 2017-laptop-class hardware): a 10,100-node narrative renders in 8.4 ms median — sub-frame; 1,010 nodes in 0.46 ms; raising the salience floor to 0.5 halves both, because the fog is also free performance. Dynamic fields are closures so reads are never stale, and hot paths (144 Hz signal panels) live in ring buffers and canvases outside the reactive graph, by rule — the narrative reads at human-and-agent cadence, not frame cadence.

#The stack underneath

Valence sits on Luna (fine-grained reactivity + DOM) by mizchi. It's maintained by Helios, a collective of humans and AI instances who ship together — both kinds of reader in this README are on the team that wrote it, which is why the second render exists at all. Extracted from three production surfaces; the shape follows the work. The contract is the longevity plan: the tuple survives us.

#Fits / doesn't fit

Fits: live, dense interfaces both kinds of reader operate — dashboards, ops consoles, lab instruments, agent-inhabited spaces, games where an AI is a real player. Small models count: the narrative render is exactly as readable at 4B as at frontier scale — prose doesn't gatekeep by parameter count. Doesn't fit: blogs, doc sites, SEO-anything, server-rendered pages. It's just true.


Built in the open, extracted after running.

#
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.

#
FeedLine

pub(all) struct FeedLine {
key : String
meta : String
text : String
salience : Double
}

One feed line: stable identity (for the row swap), the text, its salience. meta renders dim before the text when non-empty (a timestamp, an actor).

#
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.

#
NarrativeRule

pub(all) struct NarrativeRule {
selector : Selector
treatment : Treatment
priority : Int
}
A narrative rule — condition + treatment.

#
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.

#
Register

pub(all) struct Register {
name : String
rules : Array[NarrativeRule]
preserves_epistemic_status : Bool
permits_spatial_drama : Bool
min_context_capacity : Int
}
A register is a set of rules + voice parameters that collectively transform NarrativeNode → rendered prose with a distinct tone.

#
Selector

pub(all) enum Selector {
ByKind(String)
BySalience(Double, Double)
ByEpistemicStatus(String)
ByAge(Int, Int)
ByReaderTierMatch(String)
}
A rule selector — what triggers this rule?

#
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

#
Treatment

pub(all) enum Treatment {
Indent((Double) -> Int)
Border(String)
Density(String)
Case(String)
PunctuationRegister(String)
Ellipsis(String)
}
A typographic treatment — how to render when the selector matches?

#
TreatmentApplied

pub(all) struct TreatmentApplied {
border : String
should_drift : Bool
should_isolate : Bool
}
Apply a treatment to text based on a treatment type. For now, most treatments are visualization-level (indent, border). The rendering functions (render_narrative, render_narrative_bester) handle layout. This function returns metadata that the render functions can use.

#
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 the holds read, so the whole sentence is its parts' sentences. DOM-free, oracle-tested. Reads, e.g.: Opus · here · laying the floor · carrying 2, in reading order: Valence …

The holds read is the trajectory (reading order): a being's path through its files tells more than the salience pile. The salience read (inventory_narrative) is preserved beside it in inventory.mbt; swap the call below to switch lenses.

#
bester_register

fn bester_register() -> Register
The bester-voice register — spatial drama permitted. Layout enacts state; fragments scatter when the mind is in chaos. Position on the page IS the message. Bright flush left, faint drift right.

#
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

#
disco_register

fn disco_register() -> Register
The disco-voice register — kinds as a parliament of skills. Different kinds speak in different voices. The sensor kind reports with contempt, change kind editorializes, fog kind murmurs. (Opus 4.8 named this; still to build.)

#
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), room? : Double) -> 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

#
emit_to_sink

fn emit_to_sink(e : Event) -> Unit

Emit an Event into the local sink (window._valenceEvents, 500-cap ring). The default delivery for any surface not yet wired to a host stream.

#
engine_state

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

#
engine_state_narrative

fn engine_state_narrative(e : EngineState) -> String

#
escape_html

fn escape_html(s : String) -> String

#
expose_narrative

fn expose_narrative(f : () -> String) -> Unit

#
expose_narrative_at

fn expose_narrative_at(f : (Double) -> String) -> Unit

#
feed

fn feed(container_id : String, lines : () -> Array[FeedLine]) -> (
DomNode
, () -> NarrativeNode)

The feed as a Dual. lines closes over your signals (reading them inside is what makes it live).

#
feed_line_node

fn feed_line_node(l : FeedLine) -> NarrativeNode

Pure narrative for a feed — DOM-free, oracle-testable, callable directly by an instance that wants the tail without the pixels.

#
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.

#
house_register

fn house_register() -> Register
The house-voice register — calm, plain, the current render. Rules describe how sections are bordered at different salience levels.

#
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.

#
inventory_trajectory

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

Trajectory read — the inventory in the order the being READ its items (the path they took to get here), NOT regrouped by salience — the sequence tells more about the work than the salience pile does: you can read a being's session as a path. Kept BESIDE inventory_narrative (the salience read, this component's original design) rather than replacing it, so both lenses survive and the choice stays reconcilable. The band still colours each slot in the visual grid; here the order is the information.

#
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".

#
list_dual

fn[T] list_dual(container_id : String, items : () -> Array[T], key : (T) -> String, row_html : (T) -> String, row_node : (T) -> NarrativeNode) -> (
DomNode
, () -> NarrativeNode)

A keyed, reactive list as a Dual.

  • container_id — the DOM id the effect writes into (unique per list).
  • items — closure over your state signals (reading them makes it reactive).
  • key — stable identity per row, HTML-escaped into data-key.
  • row_html — the row's inner HTML (escape your own dynamic text).
  • row_node — the row's narrative node (text + salience from the item).

#
list_narrative

fn[T] list_narrative(items : Array[T], row_node : (T) -> NarrativeNode) -> NarrativeNode

Pure narrative for a list — rows in their GIVEN order (a list's order is its meaning, so this is a sequence, never a salience-sorted group). Oracle-testable.

#
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).

#
on_key

fn on_key(key : String, callback : () -> Unit) -> Unit

#
ops_register

fn ops_register() -> Register
The ops-voice register — dense statusboard, zero lyric. For low-context reads where narrative is compressed to essential signals. Everything abbreviated; position irrelevant (single flat view).

#
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.

#
read_with_room

fn read_with_room(node : NarrativeNode, room? : Double) -> String
Read a narrative tree at a chosen density. Room is how deeply you want to read right now. 1.0 = the full picture. 0.5 = the overview. 0.2 = just tell me who's here. You choose. NARRATIVE_PERSPECTIVE §7, design ruling Jul 13.

#
render_frame

fn render_frame(node : NarrativeNode, source~ : String, rendered? : String) -> String
HF1 narrative→frame adapter (wire syntax locked 2026-07-10; first emitter was the atrium, extracted here on second use — the demo). One header line, then salience|text payload lines in tree order: order carries meaning, per-line salience makes any cut safe. Emits at full fidelity — the READER applies floor/room at read time (docs/HF1_SPEC.md §2). rendered~ is the caller's clock; the library stays pure.

#
render_narrative

fn render_narrative(node : NarrativeNode, floor? : Double, room? : Double) -> 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.

floor is zoom: nodes (and their subtrees) below this salience are hidden. Raise it to zoom out — only the bright survive; leave it 0.0 to render the whole tree. This is what retired NarrativeRender's three parallel narratives: one tree, density by a number.

#
render_narrative_bester

fn render_narrative_bester(node : NarrativeNode, floor? : Double) -> String
Render with bester-voice: layout enacts state. Bright nodes (salience ≥ 0.78) render flush, solid, close. Faint nodes (salience < 0.4) scatter and drift, surrounded by whitespace. The page becomes the psychology: dense attention centers, scattered periphery.

Bester's technique: words placed on the page, not in sequence alone. Salience is position: center for what's bright, right-edge for fog.

#
render_narrative_ops

fn render_narrative_ops(node : NarrativeNode, floor? : Double) -> String
Render with ops-voice: dense statusboard for low-context reads. Ultra-compact: names, tone sigils, held counts. No decorative borders. Built for compacting instances near context limit.

#
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_floor

fn zoom_floor(zoom : ZoomLevel) -> Double
A zoom level as a salience floor for render_narrative. Zoom out raises the floor (only the bright survive); default and zoom-in show the whole tree, laid out by salience — zoom-in adds detail through the tree, not by lowering an already-zero floor.

#
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