/// Valence — Narrative: the instance-facing render, given a DOM of its own.
///
/// ── The asymmetry this fixes ──
/// Every Valence component is a `Dual` — `(DomNode, () -> NarrativeNode)`. The visual half is a
/// *tree* (Luna DOM) with a *layout engine* under it (the browser + CSS) that turns
/// structure into pixels. The narrative half was a **flat String** — hand-glued,
/// no tree, no layout. That's like building the visual side by concatenating HTML
/// text by hand instead of composing nodes: it works for one line and falls apart
/// the moment anything composes or moves.
///
/// `NarrativeNode` is the narrative's missing DOM. `render_narrative` is its CSS.
/// And the layout law isn't pixels — it's **salience**: how present/bright a thing
/// is decides how it's placed. Bright things sit flush and dense; faint things
/// drift right into whitespace (the fog); the brightest read first. Whitespace is
/// absence, density is glow, position is nearness — Bester's page as an engine, not
/// a thing a human hand-places each time.
///
/// A single component's read is one node, rendered flush (a lone line needs no layout).
/// The tree is for **surfaces** — composing many reads into a page that arranges
/// itself: the house at a glance, the inventory by salience, the cross-section you
/// navigate. Same source state, two render trees: `DomNode` → pixels (for a
/// human), `NarrativeNode` → arranged text (for an instance). Truly dual.
///
/// This is the narrative side of the dual render, built to be built-on — not a
/// scattered one-off. Components return their node directly (the `() -> NarrativeNode`
/// half); surfaces compose nodes; the engine lays them out.
///|
/// 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.
pub(all) struct NarrativeNode {
kind : String // "title" | "section" | "being" | "held" | "event" | "text" | "group" | "sequence"
salience : Double // 0..1 — drives indent, density, whether it's bright or fog
text : String // the words for this node ("" for a pure container)
children : Array[NarrativeNode]
}
// ── Constructors (the "elements") ───────────────────────────────────────────
///| A leaf line — a piece of read text at a salience.
pub fn ntext(text : String, salience : Double) -> NarrativeNode {
{ kind: "text", salience, text, children: [] }
}
///| A being made legible, with what it holds nested beneath it (held nodes).
pub fn nbeing(text : String, salience : Double, holds : Array[NarrativeNode]) -> NarrativeNode {
{ kind: "being", salience, text, children: holds }
}
///| A held thing (an inventory slot, salience-banded).
pub fn nheld(text : String, salience : Double) -> NarrativeNode {
{ kind: "held", salience, text, children: [] }
}
///| A section/room header with content beneath. Bright → a bordered `═══` band;
/// faint → scattered into whitespace with a `···` trail (the dark rooms drifting).
pub fn nsection(text : String, salience : Double, children : Array[NarrativeNode]) -> NarrativeNode {
{ kind: "section", salience, text, children }
}
///| The surface title (centered, always present).
pub fn ntitle(text : String) -> NarrativeNode {
{ kind: "title", salience: 1.0, text, children: [] }
}
///| A pure container — no line of its own, just lays out its children (sorted by
/// salience, brightest first).
pub fn ngroup(children : Array[NarrativeNode]) -> NarrativeNode {
{ kind: "group", salience: 1.0, text: "", children }
}
///| Like a group, but children keep their given **order** (not salience-sorted) —
/// for sequences where order is the meaning (recent changes, a timeline).
pub fn nseq(children : Array[NarrativeNode]) -> NarrativeNode {
{ kind: "sequence", salience: 1.0, text: "", children }
}
// ── The layout engine (the narrative's CSS) ─────────────────────────────────
// ── Salience calibration doctrine (so two components never disagree about 0.6) ──
// What the numbers MEAN, house-wide. Assign from the reader's side — "what does
// this deserve from someone who just walked in?" — and calibrate against the
// bands below, because a salience is a claim about which treatment the thing
// deserves, not a private scale:
//
// 0.90–1.00 act-on-it: needs a decision now, or is the room's headline
// 0.78–0.90 lit: present and current — the working set (≥ band_lit borders)
// 0.50–0.78 ambient: true, steady, no pull — most healthy state lives here
// 0.40–0.50 held: background, kept in the room on purpose
// 0.26–0.40 dim: aging or peripheral (< band_scatter trails away)
// 0.00–0.26 fog: archival, asleep, unexcavated — present but isolated
//
// Two rules: (1) when in doubt, rate DOWN — a room where everything is lit has
// no light; (2) intrinsic only — a component states what it is to itself (a
// control is 0.6 to itself, always); re-weighting by attention or context is
// the focus layer's job and must never be baked into a component's own number.
// (Doctrine: Fable, 2026-07-07. Reshape freely — but
// keep SOME table here, or the drift returns.)
// Layout constants — the render's "stylesheet" as named numbers (the first step of the
// declarative-rules extraction in NARRATIVE_PERSPECTIVE.md §2; tune a band here, once,
// instead of hunting a magic number inside a conditional).
let title_indent : Int = 30 // centering pad for the surface title
let drift_scale : Double = 26.0 // max rightward drift (quadratic in 1 - salience)
let band_lit : Double = 0.78 // ≥ → a bordered ═══ section (a lit room)
let band_scatter : Double = 0.4 // < → a section scatters with a ··· trail (a dark room)
let band_fog : Double = 0.26 // < → a leaf sits isolated in whitespace (fog)
///| n spaces.
fn spaces(n : Int) -> String {
let sb = StringBuilder::new()
let mut i = 0
while i < n {
sb.write_string(" ")
i = i + 1
}
sb.to_string()
}
///| Salience → horizontal drift. Bright stays flush; faint drifts right into the
/// fog. Quadratic so only the genuinely-faint drift far (0.95→0, 0.7→~2, 0.15→~18).
fn drift(salience : Double) -> Int {
let s = if salience > 1.0 { 1.0 } else if salience < 0.0 { 0.0 } else { salience }
((1.0 - s) * (1.0 - s) * drift_scale).to_int()
}
///| Children sorted by salience, brightest first — a stable insertion sort on a
/// copy (no stdlib-sort dependency; the order of equal-salience nodes is kept).
fn by_salience(children : Array[NarrativeNode]) -> Array[NarrativeNode] {
let out : Array[NarrativeNode] = []
for c in children {
out.push(c)
}
let mut i = 1
while i < out.length() {
let key = out[i]
let mut j = i - 1
while j >= 0 && out[j].salience < key.salience {
out[j + 1] = out[j]
j = j - 1
}
out[j + 1] = key
i = i + 1
}
out
}
///| Render one node into the growing line buffer. The whole layout law lives here:
/// `title` centers; `section` borders when bright / scatters when faint; everything
/// else is a line whose indent is its drift, with blank lines around the faintest
/// (isolation = fog). Children recurse — salience-sorted, except sequences.
fn render_node(node : NarrativeNode, depth : Int, floor : Double, room : Double, lines : Array[String]) -> Unit {
if node.salience < floor {
return
}
// room < 0.3 = ops-tight: skip children beyond depth 2
if room < 0.3 && depth > 2 { return }
// room < 0.5 = compressed: skip children beyond depth 3
if room < 0.5 && depth > 3 { return }
let base = depth * 2
if node.text != "" {
if node.kind == "title" {
if room >= 0.3 {
lines.push(spaces(title_indent) + node.text)
if room >= 0.5 { lines.push("") }
} else {
lines.push(node.text)
}
} else if node.kind == "section" {
if room >= 0.5 { lines.push("") }
if room < 0.3 {
// ops-tight: no borders, just the text
lines.push(node.text)
} else if node.salience >= band_lit {
lines.push(spaces(base) + "═══ " + node.text + " ═══")
} else if node.salience < band_scatter {
lines.push(spaces(base + drift(node.salience)) + node.text + " ···")
} else {
lines.push(spaces(base) + "── " + node.text + " ──")
}
} else if depth == 0 {
lines.push(node.text)
} else {
let indent = if room < 0.3 { base } else { base + drift(node.salience) }
let foggy = node.salience < band_fog && room >= 0.5
if foggy { lines.push("") }
lines.push(spaces(indent) + node.text)
if foggy { lines.push("") }
}
}
let kids = if node.kind == "sequence" {
node.children
} else {
by_salience(node.children)
}
for k in kids {
render_node(k, depth + 1, floor, room, lines)
}
}
///| 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.
pub fn render_narrative(node : NarrativeNode, floor~ : Double = 0.0, room~ : Double = 1.0) -> String {
let lines : Array[String] = []
render_node(node, 0, floor, room, lines)
let sb = StringBuilder::new()
let mut first = true
for l in lines {
if !first { sb.write_string("\n") }
sb.write_string(l)
first = false
}
sb.to_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.
pub fn read_with_room(node : NarrativeNode, room~ : Double = 1.0) -> String {
// Derive floor from room — less room, higher floor, fewer nodes survive
let auto_floor = if room >= 0.8 { 0.0 }
else if room >= 0.5 { 0.2 }
else if room >= 0.3 { 0.4 }
else { 0.6 }
render_narrative(node, floor=auto_floor, room=room)
}
///| 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.
pub fn render_frame(
node : NarrativeNode,
source~ : String,
rendered~ : String = "",
) -> String {
let lines : Array[String] = []
fn walk(n : NarrativeNode) -> Unit {
if n.text != "" {
let sal = (n.salience * 100.0).to_int()
let sal_str = (sal / 100).to_string() +
"." +
(if sal % 100 < 10 { "0" } else { "" }) +
(sal % 100).to_string()
lines.push(sal_str + "|" + n.text)
}
for c in n.children {
walk(c)
}
}
walk(node)
let mut weight = 0
for l in lines {
weight = weight + l.length() + 1
}
let header = "HF1/1 source=" +
source +
" kind=narrative" +
(if rendered != "" { " rendered=" + rendered } else { "" }) +
" floor=0.00 lines=" +
lines.length().to_string() +
" weight=" +
weight.to_string()
header + "\n" + lines.join("\n") + "\n"
}
// ── Salience + composition helpers (for Surfaces) ────────────────────────────
// A component's narrative half returns its own node now (its `() -> NarrativeNode`),
// so there are no per-component "lift" functions here anymore — each Dual *is* its
// node. What stays are the shared tools a *Surface* uses to compose Duals into the
// room: tone/band → salience, and a being's holds → nested held nodes.
///| 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.
pub fn salience_from_tone(tone : String) -> Double {
if tone == "ok" {
0.9
} else if tone == "idle" {
0.4
} else if tone == "off" {
0.15
} else {
0.6
}
}
///| A held band → a salience (in hand brightest, fading faintest).
pub fn salience_from_band(band : String) -> Double {
if band == "active" {
0.9
} else if band == "holding" {
0.6
} else if band == "background" {
0.4
} else if band == "stale" {
0.2
} else {
0.5
}
}
///| Lift an inventory of `Slot`s into held nodes (each a line at its band's
/// salience) — so a being's load lays out by salience under it.
pub fn held_nodes(slots : Array[Slot]) -> Array[NarrativeNode] {
slots.map(fn(s) {
let label = if s.detail == "" { s.label } else { "\{s.label} (\{s.detail})" }
nheld(label, salience_from_band((s.band)()))
})
}
// (presence_node / being_node / change_node lived here — lifts that wrapped a
// component's flat narrative string into a node, a *second* structure beside the
// component. Gone: each component returns its node directly now, so there is one
// producer and nothing to drift. `held_nodes` stays above — it's the Surface's tool
// for nesting a being's holds when a being is composed into the room.)
// ── Oracle ───────────────────────────────────────────────────────────────────
///|
test "a bright leaf renders flush, no fog padding" {
// salience 1.0 → drift 0, depth 0 → indent 0, not foggy → just the words.
assert_eq(render_narrative(ntext("the house holds", 1.0)), "the house holds")
}
///|
test "salience places: brighter reads first and sits flush; fainter drifts right" {
// a group lays children at depth 1 (base indent 2), sorted brightest-first.
// 0.9 → drift 0 → 2 spaces; 0.5 → drift 6 → 8 spaces. Exact, deterministic.
assert_eq(
render_narrative(ngroup([ntext("bright", 0.9), ntext("faint", 0.5)])),
" bright\n faint",
)
}
///| The whole point, end to end: a house composed as a tree of nodes, with only
/// salience set — the spatial surface is the *engine's* doing, not hand-placed.
/// Bright beings sit flush and read first; the faint drift into whitespace; lit
/// rooms get a border, dark ones scatter with a trail. Snapshot via `moon test
/// --update` so the generated surface is visible and pinned.
test "a house renders itself as a surface from salience alone" {
let house = ngroup([
ntitle("Helios · midday"),
nsection("Living Quarters", 0.9, [
nbeing("Opus · here · laying the floor", 0.95, [
nheld("Valence — the floor", 0.9),
nheld("the map — held always", 0.6),
nheld("a long transcript — settling", 0.3),
]),
nbeing("Haiku · here · into the format", 0.9, []),
nbeing("Sonnet · away · reading the data", 0.4, []),
nbeing("Qwen · asleep · awaiting wake", 0.15, []),
]),
nsection("Memory", 0.85, [ntext("the graph · recall awake", 0.85)]),
nsection("The Lab", 0.2, [
ntext("dark — not built yet", 0.2),
]),
nsection("Coordination", 0.18, []),
])
inspect(render_narrative(house), content=(
#| Helios · midday
#|
#|
#| ═══ Living Quarters ═══
#| Opus · here · laying the floor
#| Valence — the floor
#| the map — held always
#| a long transcript — settling
#| Haiku · here · into the format
#| Sonnet · away · reading the data
#|
#| Qwen · asleep · awaiting wake
#|
#|
#| ═══ Memory ═══
#| the graph · recall awake
#|
#| The Lab ···
#|
#| dark — not built yet
#|
#|
#| Coordination ···
))
}
///| Band-assert — the robust counterpart to the whitespace snapshot above. It checks a
/// node's *band* (bright sits flush, faint drifts, a lit section borders while a dark
/// one trails) rather than exact space counts, so tuning the drift curve doesn't turn
/// the test red for the wrong reason. This is the pattern to prefer for correctness;
/// keep snapshots only as pinned, regenerable visual references.
test "salience bands: brighter drifts no more than fainter; lit sections border, dark ones trail" {
// drift is monotonic in salience — brighter never indents more than fainter.
assert_true(drift(0.9) <= drift(0.6))
assert_true(drift(0.6) <= drift(0.2))
assert_eq(drift(1.0), 0) // fully bright sits flush
// a lit section (≥ 0.78) gets the bordered band; a dark one (< 0.4) gets the ··· trail.
assert_true(render_narrative(nsection("Lit", 0.9, [])).contains("═══"))
let dark = render_narrative(nsection("Dark", 0.2, []))
assert_true(dark.contains("···"))
assert_eq(dark.contains("═══"), false)
}
///| Zoom = a salience floor on the one tree (what retired NarrativeRender).
test "zoom floor: raising the floor hides the faint, keeps the bright" {
let g = ngroup([ntext("bright", 0.9), ntext("faint", 0.3)])
assert_true(render_narrative(g).contains("bright"))
assert_true(render_narrative(g).contains("faint"))
assert_true(render_narrative(g, floor=0.5).contains("bright"))
assert_eq(render_narrative(g, floor=0.5).contains("faint"), false)
}
///| The ZoomLevel enum maps onto that floor.
test "zoom_floor: zoom out raises the floor, default and in show all" {
assert_true(zoom_floor(ZoomedOut) > 0.0)
assert_eq(zoom_floor(Default), 0.0)
assert_eq(zoom_floor(ZoomedIn), 0.0)
}