/// Valence — the keyed list: the escape hatch institutionalized.
///
/// Field data (gesture-lab, July 6 2026): ADR-003's innerHTML hatch beat
/// fine-grained per-row reactivity three times in one night — device tracks,
/// ruler ticks, session rows. When the escape hatch wins every fight it is not
/// an escape hatch anymore; it is the de-facto list renderer, used bare,
/// without a narrative half — the contract's weakest joint. This file puts the
/// winning pattern BEHIND the contract: one atomic innerHTML swap per change
/// (instant paint, one DOM operation regardless of row count), plus the
/// narrative half the bare hatch always dropped.
///
/// The stale-slot law, baked in (July 6, the ghost-sessions bug): identity is
/// never stamped at build time and trusted later — every render regenerates
/// every row from the live items, so no recycled node exists to go stale. Rows
/// carry `data-key` for the consumer's event delegation: read the key from the
/// DOM at *click* time (one listener on the container), never capture row
/// identity in a closure at build time.
///
/// Wiring notes for consumers:
/// - `items` closes over your state signals; reading them inside the closure is
/// what makes the rebuild reactive.
/// - The effect no-ops until the container is mounted — trigger one refresh
/// after mount (touch the signal), the house's existing pattern.
/// - `row_html` is raw HTML by design (that's the hatch): pass any dynamic text
/// through `escape_html` yourself, same rule as `update_element_html`.
///
/// (Extracted by Fable, 2026-07-07, from the gesture-lab case.)
///|
/// 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.
pub fn[T] list_narrative(
items : Array[T],
row_node : (T) -> NarrativeNode,
) -> NarrativeNode {
nseq(items.map(fn(it) { row_node(it) }))
}
///|
/// 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).
pub fn[T] list_dual(
container_id : String,
items : () -> Array[T],
key : (T) -> String,
row_html : (T) -> String,
row_node : (T) -> NarrativeNode,
) -> Dual {
let _ = effect(fn() {
let rows = items() // read the signals first — reactive even pre-mount
if element_exists(container_id) {
let sb = StringBuilder::new()
for it in rows {
sb.write_string("")
sb.write_string(row_html(it))
sb.write_string("
")
}
update_element_html(container_id, sb.to_string())
}
})
let visual = div(id=container_id, class="valence-list") <| []
let narrative = fn() { list_narrative(items(), row_node) }
(visual, narrative)
}
// ── Oracle ──
///|
test "a list narrates its rows in given order, even when salience disagrees" {
// nseq keeps order (0.5 first stays first); a group would sort 0.9 to the top.
// Indents are the drift math pinned by narrative.mbt's own tests.
assert_eq(
render_narrative(nseq([ntext("faint", 0.5), ntext("bright", 0.9)])),
" faint\n bright",
)
}
///|
test "list_narrative wraps rows as a sequence" {
let n = list_narrative(["one", "two"], fn(s) { ntext(s, 0.6) })
let txt = render_narrative(n)
assert_true(txt.contains("one"))
assert_true(txt.contains("two"))
}