/// 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")) }