Browse Source

First pass

Billy Barrow 3 weeks ago
parent
commit
edb5cdb9c3
4 changed files with 1796 additions and 0 deletions
  1. 299 0
      frontend-demo/index.html
  2. 329 0
      frontend-demo/server.php
  3. 9 0
      future.md
  4. 1159 0
      js/statum.js

+ 299 - 0
frontend-demo/index.html

@@ -0,0 +1,299 @@
+<!doctype html>
+<html lang="en">
+
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Statum Demo</title>
+  <style>
+    :root {
+      --bg: #f6f7f9;
+      --card: #fff;
+      --line: #e3e6ea;
+      --accent: #3a6ea5;
+      --muted: #6b7280;
+    }
+
+    * {
+      box-sizing: border-box;
+    }
+
+    body {
+      margin: 0;
+      font-family: system-ui, Segoe UI, Roboto, sans-serif;
+      background: var(--bg);
+      color: #1f2933;
+      line-height: 1.5;
+    }
+
+    header {
+      background: var(--accent);
+      color: #fff;
+      padding: 1rem 1.5rem;
+      display: flex;
+      align-items: baseline;
+      gap: 1rem;
+      flex-wrap: wrap;
+    }
+
+    header h1 {
+      margin: 0;
+      font-size: 1.25rem;
+    }
+
+    header .role {
+      margin-left: auto;
+      opacity: .9;
+    }
+
+    main {
+      max-width: 900px;
+      margin: 0 auto;
+      padding: 1.5rem;
+    }
+
+    section {
+      background: var(--card);
+      border: 1px solid var(--line);
+      border-radius: 10px;
+      padding: 1rem 1.25rem;
+      margin-bottom: 1.25rem;
+    }
+
+    h2 {
+      margin-top: 0;
+      font-size: 1.05rem;
+      color: var(--muted);
+      text-transform: uppercase;
+      letter-spacing: .04em;
+    }
+
+    button {
+      font: inherit;
+      padding: .35rem .8rem;
+      border: 1px solid var(--line);
+      border-radius: 6px;
+      background: #fff;
+      cursor: pointer;
+    }
+
+    button:hover {
+      border-color: var(--accent);
+    }
+
+    button:disabled {
+      opacity: .55;
+      cursor: progress;
+    }
+
+    /* stm-class targets */
+    .high {
+      color: #b00020;
+      font-weight: 700;
+    }
+
+    .featured {
+      border-color: #f0a500;
+      box-shadow: 0 0 0 2px #f0a50033;
+    }
+
+    .out {
+      opacity: .5;
+    }
+
+    /* stm-busy-class */
+    .busy {
+      outline: 2px solid var(--accent);
+      outline-offset: 2px;
+    }
+
+    /* stm-on-error */
+    .error {
+      border-color: #b00020;
+      background: #fde8ea;
+    }
+
+    .ok {
+      background: #e6f4ea;
+      border: 1px solid #b7deca;
+      padding: .5rem .75rem;
+      border-radius: 6px;
+    }
+
+    .counter {
+      display: inline-flex;
+      align-items: center;
+      gap: .75rem;
+    }
+
+    .counter .value {
+      min-width: 2.5ch;
+      text-align: center;
+      font-size: 1.5rem;
+    }
+
+    .grid {
+      display: grid;
+      grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+      gap: 1rem;
+    }
+
+    .card {
+      border: 1px solid var(--line);
+      border-radius: 8px;
+      padding: .75rem;
+    }
+
+    .card h3 {
+      margin: 0 0 .25rem;
+    }
+
+    .price {
+      color: var(--muted);
+    }
+
+    .pill {
+      display: inline-block;
+      font-size: .75rem;
+      padding: .1rem .5rem;
+      border-radius: 999px;
+      background: #eef2f7;
+      color: var(--muted);
+    }
+
+    .pill.stock {
+      background: #e6f4ea;
+      color: #1e7e34;
+    }
+
+    .pill.soldout {
+      background: #fde8ea;
+      color: #b00020;
+    }
+
+    ul.lines {
+      padding-left: 1.1rem;
+    }
+
+    .muted {
+      color: var(--muted);
+    }
+  </style>
+</head>
+
+<!--
+  Body-level overrides (optional): stm-entrypoint / stm-slots point the library
+  at this server's endpoints. They default to /_statum/entrypoint and
+  /_statum/slots, so they are shown here only to demonstrate the overrides.
+-->
+<body stm-entrypoint="/_statum/entrypoint" stm-slots="/_statum/slots">
+
+  <header>
+    <h1>Statum Demo</h1>
+    <!-- stm-text -->
+    <span>Role: <span stm-text="user.name">Guest</span></span>
+    <span class="role">
+      <!-- stm-if / stm-else-if / stm-else chain -->
+      <span stm-if="user.role === 'admin'">administrator tools enabled</span>
+      <span stm-else-if="user.role === 'member'">member tools</span>
+      <span stm-else>read-only guest</span>
+    </span>
+    <button stm-action="user.toggleAction">Toggle role</button>
+  </header>
+
+  <main>
+    <!-- stm-preloader is shown until the entrypoint resolves; stm-content after. -->
+    <div stm-preloader>Loading application state…</div>
+
+    <div stm-content>
+      <!-- Counter: stm-text, stm-class.x, stm-if, stm-action, stm-data-* -->
+      <section>
+        <h2>Counter <span class="muted">(stm-text · stm-class · stm-if · stm-data)</span></h2>
+        <div class="counter">
+          <button stm-action="counter.decrement">−</button>
+          <!-- stm-class.high toggles when value > 5 -->
+          <span class="value" stm-text="counter.value" stm-class.high="counter.value > 5">0</span>
+          <button stm-action="counter.increment">+</button>
+          <button stm-action="counter.reset">Reset</button>
+          <!-- stm-data-amount sends a literal value with the request -->
+          <button stm-action="counter.add" stm-data-amount="5">+5</button>
+        </div>
+        <!-- stm-if on a derived boolean -->
+        <p stm-if="counter.high">That is a high number!</p>
+      </section>
+
+      <!-- Products: stm-for-..-in, stm-key, stm-class, stm-if/else, stm-action, stm-busy-class -->
+      <section>
+        <h2>Products <span class="muted">(stm-for · stm-key · stm-class · stm-action · stm-busy-class)</span></h2>
+        <div class="grid">
+          <div class="card"
+               stm-for-product-in="products.items"
+               stm-key="product.id"
+               stm-class.featured="product.featured"
+               stm-class.out="!product.inStock">
+            <h3 stm-text="product.name">Product</h3>
+            <div class="price">$<span stm-text="product.price">0</span></div>
+            <p>
+              <span class="pill stock" stm-if="product.inStock">In stock</span>
+              <span class="pill soldout" stm-else>Sold out</span>
+              <span class="pill" stm-if="product.featured">featured</span>
+            </p>
+            <!--
+              Per-item context (the product id) is baked into the action's
+              `private` field server-side, so no stm-data expression is needed.
+              stm-busy-class applies while the request is in flight.
+            -->
+            <button stm-action="product.addToCartAction"
+                    stm-busy-class="busy"
+                    stm-data-confirm="true">Add to cart</button>
+          </div>
+        </div>
+      </section>
+
+      <!-- Cart: stm-text, stm-if/else, nested stm-for, and the always-send slot -->
+      <section>
+        <h2>Cart <span class="muted">(stm-text · stm-if/else · nested stm-for)</span></h2>
+        <p stm-if="cart.empty">Your cart is empty.</p>
+        <div stm-else>
+          <p><strong>Items:</strong> <span stm-text="cart.count">0</span>
+             &nbsp; <strong>Total:</strong> $<span stm-text="cart.total">0</span></p>
+          <ul class="lines">
+            <!-- nested loop inside an stm-else branch -->
+            <li stm-for-line-in="cart.items" stm-key="line.id">
+              <span stm-text="line.name"></span> ×
+              <span stm-text="line.qty"></span>
+              ($<span stm-text="line.price"></span>)
+            </li>
+          </ul>
+          <button stm-action="cart.checkoutAction">Checkout</button>
+          <button stm-action="cart.clearAction">Clear cart</button>
+        </div>
+      </section>
+
+      <!-- Notice: stm-html, and an error directive to demonstrate stm-on-error -->
+      <section>
+        <h2>Notice <span class="muted">(stm-html · stm-on-error)</span></h2>
+        <!-- stm-html renders server-provided HTML content -->
+        <div stm-html="notice.html">No notice set.</div>
+        <p>
+          <button stm-action="notice.setAction">Set HTML notice</button>
+          <!-- stm-on-error applies these classes when the action fails -->
+          <button stm-action="notice.failAction" stm-on-error="error">Trigger error</button>
+        </p>
+      </section>
+
+      <section>
+        <h2>Session</h2>
+        <p>
+          <!-- Demonstrates a `navigate` directive (full reload) + `clear` directives. -->
+          <button stm-action="user.resetAllAction">Reset everything</button>
+        </p>
+      </section>
+    </div>
+  </main>
+
+  <!-- The library auto-initialises on DOMContentLoaded. -->
+  <script src="/statum.js"></script>
+</body>
+
+</html>

+ 329 - 0
frontend-demo/server.php

@@ -0,0 +1,329 @@
+<?php
+/**
+ * Statum frontend demo backend.
+ *
+ * Run from this directory with PHP's built-in server:
+ *
+ *     php -S localhost:8000 server.php
+ *
+ * Then open http://localhost:8000/ in your browser.
+ *
+ * This is a DEMO only: there is no real signing or encryption. Snapshots are
+ * wrapped in trivial frames, and per-item action context is carried in the
+ * action's `private` blob as plain base64. State lives in the PHP session so
+ * that actions are stateful across requests.
+ */
+
+session_start();
+
+// Static product catalog used by the demo.
+$CATALOG = [
+    ['id' => 1, 'name' => 'Widget',  'price' => 9.99,  'featured' => true,  'inStock' => true],
+    ['id' => 2, 'name' => 'Gadget',  'price' => 19.99, 'featured' => false, 'inStock' => true],
+    ['id' => 3, 'name' => 'Sprocket', 'price' => 4.50, 'featured' => false, 'inStock' => false],
+    ['id' => 4, 'name' => 'Gizmo',   'price' => 29.99, 'featured' => true,  'inStock' => true],
+];
+
+$TYPES = ['counter', 'products', 'cart', 'user', 'notice'];
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Send a JSON array of Statum directives and stop. */
+function statum_respond(array $directives): void
+{
+    header('Content-Type: application/vnd.statum+json');
+    header('Cache-Control: no-store');
+    echo json_encode($directives, JSON_PRETTY_PRINT);
+    exit;
+}
+
+/** Wrap a snapshot in a trivial (unsigned) frame. */
+function frame(array $snapshot): array
+{
+    return ['content' => json_encode($snapshot), 'signer' => 'demo', 'signature' => ''];
+}
+
+/** Stable per-type slot key, kept in the session. */
+function key_for(string $type): string
+{
+    if (!isset($_SESSION['keys'][$type])) {
+        $_SESSION['keys'][$type] = 'k_' . $type;
+    }
+    return $_SESSION['keys'][$type];
+}
+
+/**
+ * Build a snapshot for a type. `always` makes transmit_after == as_at so the
+ * client sends the base64 frame on every request (demonstrating that path).
+ */
+function make_snapshot(string $type, string $scope, array $public, array $opts = []): array
+{
+    $now = gmdate('Y-m-d\TH:i:s\Z');
+    return [
+        'as_at' => $now,
+        'slot' => ['key' => key_for($type), 'scope' => $scope, 'type' => $type],
+        'transmit_after' => !empty($opts['always']) ? $now : gmdate('Y-m-d\TH:i:s\Z', time() + 60),
+        'invalid_after' => gmdate('Y-m-d\TH:i:s\Z', time() + 600),
+        'private' => $opts['private'] ?? '',
+        'public' => $public,
+    ];
+}
+
+function set_dir(array $snapshot): array
+{
+    return ['type' => 'set', 'snapshot_frame' => frame($snapshot)];
+}
+
+function clear_dir(string $key): array
+{
+    return ['type' => 'clear', 'key' => $key];
+}
+
+function product_by_id($id): ?array
+{
+    global $CATALOG;
+    foreach ($CATALOG as $p) {
+        if ((string)$p['id'] === (string)$id) return $p;
+    }
+    return null;
+}
+
+// ---------------------------------------------------------------------------
+// Public-data builders
+// ---------------------------------------------------------------------------
+
+function build_counter(): array
+{
+    $v = $_SESSION['counter'] ?? 0;
+    return [
+        'value' => $v,
+        'high' => $v > 5,
+        'increment' => ['uri' => '/api/counter/inc', 'method' => 'POST'],
+        'decrement' => ['uri' => '/api/counter/dec', 'method' => 'POST'],
+        'reset' => ['uri' => '/api/counter/reset', 'method' => 'POST'],
+        'add' => ['uri' => '/api/counter/add', 'method' => 'POST'],
+    ];
+}
+
+function build_products(): array
+{
+    global $CATALOG;
+    $items = [];
+    foreach ($CATALOG as $p) {
+        $pid = (string)$p['id'];
+        $items[] = [
+            'id' => $pid,
+            'name' => $p['name'],
+            'price' => $p['price'],
+            'featured' => $p['featured'],
+            'inStock' => $p['inStock'],
+            // Per-item context rides in `private`; the client echoes it back
+            // via the X-Statum-Private header (no stm-data expression needed).
+            'addToCartAction' => [
+                'uri' => '/api/cart/add',
+                'method' => 'POST',
+                'private' => base64_encode('id=' . $pid),
+            ],
+        ];
+    }
+    return ['items' => $items];
+}
+
+function build_cart(): array
+{
+    $cart = $_SESSION['cart'] ?? [];
+    $count = 0;
+    $total = 0.0;
+    $lines = [];
+    foreach ($cart as $id => $qty) {
+        $p = product_by_id($id);
+        if (!$p) continue;
+        $count += $qty;
+        $total += $p['price'] * $qty;
+        $lines[] = ['id' => (string)$id, 'name' => $p['name'], 'qty' => $qty, 'price' => $p['price']];
+    }
+    return [
+        'count' => $count,
+        'total' => round($total, 2),
+        'empty' => $count === 0,
+        'items' => $lines,
+        'checkoutAction' => ['uri' => '/api/cart/checkout', 'method' => 'POST'],
+        'clearAction' => ['uri' => '/api/cart/clear', 'method' => 'POST'],
+    ];
+}
+
+function build_user(): array
+{
+    $role = $_SESSION['role'] ?? 'guest';
+    $names = ['guest' => 'Guest', 'member' => 'Member Mike', 'admin' => 'Admin Alice'];
+    return [
+        'role' => $role,
+        'name' => $names[$role] ?? 'Guest',
+        'toggleAction' => ['uri' => '/api/user/toggle', 'method' => 'POST'],
+        'resetAllAction' => ['uri' => '/api/reset-all', 'method' => 'POST'],
+    ];
+}
+
+function build_notice(): array
+{
+    return [
+        'html' => $_SESSION['notice'] ?? '<em>No notice set.</em>',
+        'setAction' => ['uri' => '/api/notice/set', 'method' => 'POST'],
+        'failAction' => ['uri' => '/api/notice/fail', 'method' => 'POST'],
+    ];
+}
+
+/** Build the snapshot for a type with its demo scope/options. */
+function snap(string $type): ?array
+{
+    switch ($type) {
+        case 'counter':  return make_snapshot('counter', 'session', build_counter());
+        case 'products': return make_snapshot('products', 'session', build_products());
+        case 'cart':     return make_snapshot('cart', 'session', build_cart(), ['always' => true]);
+        case 'user':     return make_snapshot('user', 'session', build_user());
+        case 'notice':   return make_snapshot('notice', 'flow', build_notice());
+    }
+    return null;
+}
+
+function all_directives(): array
+{
+    global $TYPES;
+    $dirs = [];
+    foreach ($TYPES as $t) {
+        $s = snap($t);
+        if ($s) $dirs[] = set_dir($s);
+    }
+    return $dirs;
+}
+
+// ---------------------------------------------------------------------------
+// Router
+// ---------------------------------------------------------------------------
+
+$method = $_SERVER['REQUEST_METHOD'];
+$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
+
+// Static assets.
+if ($path === '/statum.js') {
+    header('Content-Type: application/javascript');
+    readfile(__DIR__ . '/../js/statum.js');
+    exit;
+}
+
+// The page itself.
+if ($path === '/' || $path === '/index.html') {
+    header('Content-Type: text/html; charset=utf-8');
+    readfile(__DIR__ . '/index.html');
+    exit;
+}
+
+// Landing page after a (post) checkout navigation.
+if ($path === '/api/checkout-done') {
+    header('Content-Type: text/html; charset=utf-8');
+    $order = htmlspecialchars($_GET['order'] ?? 'UNKNOWN');
+    echo "<!doctype html><meta charset=utf-8><title>Checkout</title>" .
+         "<body style='font-family:sans-serif;padding:2rem'>" .
+         "<h1>Order $order placed</h1><p>This page was reached via a statum <code>post</code> directive.</p>" .
+         "<p><a href='/'>Back to the demo</a></p>";
+    exit;
+}
+
+// --- Statum endpoints -------------------------------------------------------
+
+if ($path === '/_statum/entrypoint') {
+    statum_respond(all_directives());
+}
+
+if ($path === '/_statum/slots') {
+    // Pre-flight target: refresh each type whose frame the client sent back.
+    $body = json_decode(file_get_contents('php://input'), true);
+    $dirs = [];
+    if (is_array($body)) {
+        foreach ($body as $fr) {
+            $snap = json_decode($fr['content'] ?? 'null', true);
+            $type = $snap['slot']['type'] ?? null;
+            if ($type) {
+                $s = snap($type);
+                if ($s) $dirs[] = set_dir($s);
+            }
+        }
+    }
+    statum_respond($dirs);
+}
+
+// --- Actions ----------------------------------------------------------------
+
+if ($path === '/api/counter/inc' && $method === 'POST') {
+    $_SESSION['counter'] = ($_SESSION['counter'] ?? 0) + 1;
+    statum_respond([set_dir(snap('counter'))]);
+}
+
+if ($path === '/api/counter/dec' && $method === 'POST') {
+    $_SESSION['counter'] = ($_SESSION['counter'] ?? 0) - 1;
+    statum_respond([set_dir(snap('counter'))]);
+}
+
+if ($path === '/api/counter/reset' && $method === 'POST') {
+    $_SESSION['counter'] = 0;
+    statum_respond([set_dir(snap('counter'))]);
+}
+
+if ($path === '/api/counter/add' && $method === 'POST') {
+    $amount = intval($_POST['amount'] ?? 1);
+    $_SESSION['counter'] = ($_SESSION['counter'] ?? 0) + $amount;
+    statum_respond([set_dir(snap('counter'))]);
+}
+
+if ($path === '/api/cart/add' && $method === 'POST') {
+    $priv = $_SERVER['HTTP_X_STATUM_PRIVATE'] ?? '';
+    $raw = base64_decode($priv, true) ?: '';
+    if (preg_match('/id=([0-9]+)/', $raw, $m)) {
+        $id = $m[1];
+        $_SESSION['cart'][$id] = ($_SESSION['cart'][$id] ?? 0) + 1;
+    }
+    statum_respond([set_dir(snap('cart'))]);
+}
+
+if ($path === '/api/cart/clear' && $method === 'POST') {
+    $_SESSION['cart'] = [];
+    statum_respond([set_dir(snap('cart'))]);
+}
+
+if ($path === '/api/cart/checkout' && $method === 'POST') {
+    // A `post` directive performs a full-page navigating form POST last.
+    statum_respond([['type' => 'post', 'uri' => '/api/checkout-done', 'data' => ['order' => 'ORD-' . strtoupper(bin2hex(random_bytes(3)))]]]);
+}
+
+if ($path === '/api/user/toggle' && $method === 'POST') {
+    $order = ['guest', 'member', 'admin'];
+    $cur = array_search($_SESSION['role'] ?? 'guest', $order);
+    $_SESSION['role'] = $order[($cur + 1) % count($order)];
+    statum_respond([set_dir(snap('user'))]);
+}
+
+if ($path === '/api/notice/set' && $method === 'POST') {
+    $_SESSION['notice'] = '<div class="ok"><strong>Notice:</strong> updated at ' . date('H:i:s') . '</div>';
+    statum_respond([set_dir(snap('notice'))]);
+}
+
+if ($path === '/api/notice/fail' && $method === 'POST') {
+    statum_respond([['type' => 'error', 'message' => 'Intentional failure.', 'code' => 'DEMO_FAIL', 'data' => ['at' => date('c')]]]);
+}
+
+if ($path === '/api/reset-all' && $method === 'POST') {
+    $dirs = [];
+    foreach ($TYPES as $t) {
+        if (isset($_SESSION['keys'][$t])) $dirs[] = clear_dir($_SESSION['keys'][$t]);
+    }
+    session_unset();
+    $dirs[] = ['type' => 'navigate', 'uri' => '/'];
+    statum_respond($dirs);
+}
+
+// Fallback 404.
+http_response_code(404);
+header('Content-Type: text/plain');
+echo "404 Not Found: $path";

+ 9 - 0
future.md

@@ -0,0 +1,9 @@
+
+- `pstm` attributes for server side functionality and composition
+- `stm-confirm` similar to `hx-confirm`.
+
+# Server side functions
+
+The server will use `pstm` attributes to pre-compute HTML files at startup.
+
+All components must have a `body` tag

+ 1159 - 0
js/statum.js

@@ -0,0 +1,1159 @@
+/*!
+ * Statum - client-side state library.
+ *
+ * An HTMX-inspired state layer. The library manages signed "snapshots" of
+ * application state (one per slot type), keeps them fresh via the server's
+ * `transmit_after` / `invalid_after` timestamps, and binds them to the DOM
+ * through `stm-*` attributes. See model.md for the full specification.
+ *
+ * Distribution: a single IIFE that attaches `window.statum` (no dependencies,
+ * no build step). Targets modern evergreen browsers (uses fetch,
+ * BroadcastChannel, queueMicrotask, URLSearchParams, FormData).
+ */
+(function () {
+  'use strict';
+
+  /** Content-Type used by every Statum directive response. */
+  var STATUM_CONTENT_TYPE = 'application/vnd.statum+json';
+  /** Request/response header carrying one slot's reference (or base64 frame). */
+  var SLOT_HEADER = 'X-Statum-Slot';
+  /** Request header carrying an action's opaque `private` blob. */
+  var PRIVATE_HEADER = 'X-Statum-Private';
+  /** Session-cookie marker used to detect a fresh browser session. */
+  var SESSION_COOKIE = '_statum_sdc';
+  /** BroadcastChannel name used to sync session/device state across tabs. */
+  var CHANNEL_NAME = 'statum';
+  /** localStorage / sessionStorage key prefix for persisted frames. */
+  var STORAGE_PREFIX = 'statum:';
+
+  // ---------------------------------------------------------------------------
+  // Configuration
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Endpoint URLs. These defaults may be overridden per-page with the
+   * `stm-entrypoint` and `stm-slots` attributes on the `<body>` element.
+   *
+   * @type {{entrypointUrl: string, slotsUrl: string}}
+   */
+  var config = {
+    entrypointUrl: '/_statum/entrypoint',
+    slotsUrl: '/_statum/slots'
+  };
+
+  // ---------------------------------------------------------------------------
+  // State store
+  // ---------------------------------------------------------------------------
+
+  /**
+   * In-memory store, keyed by slot type. Each value is `{frame, snapshot}`
+   * where `snapshot` is the parsed `frame.content`. This map is the single
+   * source of truth for rendering; persistent backends merely hydrate it.
+   *
+   * @type {Map<string, {frame: object, snapshot: object}>}
+   */
+  var store = new Map();
+
+  /** Listeners, keyed by type name. @type {Map<string, Set<Function>>} */
+  var listeners = new Map();
+
+  /** @type {BroadcastChannel|null} */
+  var channel = null;
+
+  /** Whether {@link init} has already run. */
+  var initialized = false;
+
+  // ---------------------------------------------------------------------------
+  // Small utilities
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Parse an ISO 8601 timestamp to epoch millis, or `undefined`.
+   * @param {string|undefined} s
+   * @returns {number|undefined}
+   */
+  function parseTime(s) {
+    if (!s) return undefined;
+    var t = new Date(s).getTime();
+    return isNaN(t) ? undefined : t;
+  }
+
+  /**
+   * Base64-encode a UTF-8 string (safe for frames containing non-Latin1 data).
+   * @param {string} str
+   * @returns {string}
+   */
+  function b64encode(str) {
+    var bytes = new TextEncoder().encode(str);
+    var bin = '';
+    var chunk = 0x8000;
+    for (var i = 0; i < bytes.length; i += chunk) {
+      bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
+    }
+    return btoa(bin);
+  }
+
+  /** Read a cookie value, or `null` if absent. @param {string} name */
+  function getCookie(name) {
+    var escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1');
+    var match = document.cookie.match(new RegExp('(?:^|; )' + escaped + '=([^;]*)'));
+    return match ? decodeURIComponent(match[1]) : null;
+  }
+
+  /** Set a session cookie (no expiry => cleared when the browser closes). */
+  function setCookie(name, value) {
+    document.cookie = name + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax';
+  }
+
+  // ---------------------------------------------------------------------------
+  // Expression evaluation
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Compiled-expression cache. Expressions are evaluated as JavaScript against
+   * a scope object (the page's HTML is trusted, per the model).
+   * @type {Map<string, Function>}
+   */
+  var exprCache = new Map();
+
+  /**
+   * Compile (and cache) an expression string into a function of `scope`.
+   * Uses `with` so that arbitrary type names and loop variables are resolved
+   * as bare identifiers. Returns a function that yields `undefined` on any
+   * runtime or compile error.
+   * @param {string} expr
+   * @returns {Function}
+   */
+  function compileExpr(expr) {
+    var cached = exprCache.get(expr);
+    if (cached !== undefined) return cached;
+    var fn;
+    try {
+      // Function-constructor bodies are non-strict by default, so `with` is
+      // permitted. The inner try/catch turns reference errors into `undefined`
+      // so a missing path simply yields no value.
+      fn = new Function('scope', 'with(scope){try{return (' + expr + ');}catch(e){return undefined;}}');
+    } catch (e) {
+      fn = function () { return undefined; };
+    }
+    exprCache.set(expr, fn);
+    return fn;
+  }
+
+  /**
+   * Evaluate an expression against a scope.
+   * @param {string} expr
+   * @param {object} [scope]
+   * @returns {*} `undefined` if the expression is empty or fails.
+   */
+  function evalExpr(expr, scope) {
+    if (expr == null || expr === '') return undefined;
+    try {
+      return compileExpr(expr)(scope || {});
+    } catch (e) {
+      return undefined;
+    }
+  }
+
+  /**
+   * Build a child scope that inherits the parent (so type bindings remain
+   * visible) and adds one own loop variable.
+   * @param {object} parent
+   * @param {string} name
+   * @param {*} value
+   * @returns {object}
+   */
+  function childScope(parent, name, value) {
+    var scope = Object.create(parent || null);
+    scope[name] = value;
+    return scope;
+  }
+
+  // ---------------------------------------------------------------------------
+  // StatumError
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Error type thrown for HTTP failures and `error` directives.
+   * @param {string} [message]
+   * @param {string} [code]
+   * @param {object} [data]
+   */
+  function StatumError(message, code, data) {
+    this.name = 'StatumError';
+    this.message = message || '';
+    this.code = code || undefined;
+    this.data = data || undefined;
+  }
+  StatumError.prototype = Object.create(Error.prototype);
+
+  // ---------------------------------------------------------------------------
+  // Persistence
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Pick the storage backend for a scope.
+   * @param {string} scope
+   * @returns {Storage|null} `null` for in-memory scopes.
+   */
+  function storageFor(scope) {
+    if (scope === 'device' || scope === 'session') return localStorage;
+    if (scope === 'flow') return sessionStorage;
+    return null; // page / transient
+  }
+
+  /**
+   * Persist a slot's frame to the backend matching its scope, removing any
+   * stale copy from the other backend first (in case the scope changed).
+   * @param {string} type
+   * @param {{frame: object, snapshot: object}} entry
+   */
+  function persistEntry(type, entry) {
+    var scope = entry.snapshot.slot && entry.snapshot.slot.scope;
+    removePersisted(type);
+    var storage = storageFor(scope);
+    if (!storage) return;
+    try {
+      storage.setItem(STORAGE_PREFIX + type, JSON.stringify(entry.frame));
+    } catch (e) {
+      /* storage full or unavailable; remain in-memory only */
+    }
+  }
+
+  /** Remove a persisted frame from both backends. @param {string} type */
+  function removePersisted(type) {
+    try { localStorage.removeItem(STORAGE_PREFIX + type); } catch (e) {}
+    try { sessionStorage.removeItem(STORAGE_PREFIX + type); } catch (e) {}
+  }
+
+  /** Load every persisted frame from a backend into the in-memory store. */
+  function loadFrom(storage) {
+    for (var i = 0; i < storage.length; i++) {
+      var key = storage.key(i);
+      if (!key || key.indexOf(STORAGE_PREFIX) !== 0) continue;
+      try {
+        var frame = JSON.parse(storage.getItem(key));
+        var snapshot = JSON.parse(frame.content);
+        var type = snapshot.slot && snapshot.slot.type;
+        if (type && !store.has(type)) store.set(type, { frame: frame, snapshot: snapshot });
+      } catch (e) {
+        /* corrupt entry: ignore */
+      }
+    }
+  }
+
+  /** Hydrate the store from localStorage and sessionStorage. */
+  function hydrate() {
+    try { loadFrom(localStorage); } catch (e) {}
+    try { loadFrom(sessionStorage); } catch (e) {}
+  }
+
+  // ---------------------------------------------------------------------------
+  // Session-cookie initialization
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Detect a fresh browser session using the `_statum_sdc` session cookie.
+   * On a fresh session, clear all `session`-scoped persisted frames, then set
+   * the marker. Session cookies are shared across tabs, so a tab opened
+   * mid-session sees the marker and leaves session data intact.
+   */
+  function initSession() {
+    if (getCookie(SESSION_COOKIE) !== null) return;
+    clearSessionSlotsFromStorage();
+    setCookie(SESSION_COOKIE, '1');
+  }
+
+  /** Remove all `session`-scoped frames from localStorage. */
+  function clearSessionSlotsFromStorage() {
+    var toRemove = [];
+    for (var i = 0; i < localStorage.length; i++) {
+      var key = localStorage.key(i);
+      if (!key || key.indexOf(STORAGE_PREFIX) !== 0) continue;
+      try {
+        var frame = JSON.parse(localStorage.getItem(key));
+        var snapshot = JSON.parse(frame.content);
+        if (snapshot.slot && snapshot.slot.scope === 'session') toRemove.push(key);
+      } catch (e) {}
+    }
+    for (var j = 0; j < toRemove.length; j++) localStorage.removeItem(toRemove[j]);
+  }
+
+  // ---------------------------------------------------------------------------
+  // Listeners & cross-tab broadcast
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Fire all listeners for a type.
+   * @param {string} type
+   * @param {object|null} frame
+   * @param {object|null} snapshot
+   */
+  function notify(type, frame, snapshot) {
+    var set = listeners.get(type);
+    if (!set) return;
+    var event = { type: type, frame: frame, snapshot: snapshot };
+    set.forEach(function (cb) {
+      try { cb(event); } catch (e) { console.error('[statum] listener error', e); }
+    });
+  }
+
+  /**
+   * Broadcast a state change to other tabs. Only `session`/`device` scopes are
+   * shared (flow/page/transient are per-tab).
+   * @param {object} message
+   * @param {string} [scope]
+   */
+  function broadcast(message, scope) {
+    if (!channel) return;
+    if (scope !== 'session' && scope !== 'device') return;
+    try { channel.postMessage(message); } catch (e) {}
+  }
+
+  /** Open the BroadcastChannel and wire inbound messages. */
+  function setupChannel() {
+    if (typeof BroadcastChannel === 'undefined') return;
+    try {
+      channel = new BroadcastChannel(CHANNEL_NAME);
+    } catch (e) {
+      channel = null;
+      return;
+    }
+    channel.onmessage = function (ev) {
+      var msg = ev.data;
+      if (!msg || msg.kind !== 'set' && msg.kind !== 'clear') return;
+      if (msg.kind === 'set' && msg.frame) {
+        try {
+          var snapshot = JSON.parse(msg.frame.content);
+          var type = snapshot.slot && snapshot.slot.type;
+          if (type) {
+            var entry = { frame: msg.frame, snapshot: snapshot };
+            store.set(type, entry);
+            persistEntry(type, entry);
+            notify(type, msg.frame, snapshot);
+            renderAll();
+          }
+        } catch (e) { /* ignore malformed */ }
+      } else if (msg.kind === 'clear' && msg.type) {
+        clearType(msg.type, { broadcast: false });
+        renderAll();
+      }
+    };
+  }
+
+  // ---------------------------------------------------------------------------
+  // State mutation
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Remove a type from the store, un-persist it, fire listeners, and
+   * (optionally) broadcast the clear to other tabs.
+   * @param {string} type
+   * @param {{broadcast?: boolean}} [opts]
+   */
+  function clearType(type, opts) {
+    var entry = store.get(type);
+    if (!entry) return;
+    var scope = entry.snapshot.slot && entry.snapshot.slot.scope;
+    store.delete(type);
+    removePersisted(type);
+    notify(type, null, null);
+    if (opts && opts.broadcast) broadcast({ kind: 'clear', type: type }, scope);
+  }
+
+  /**
+   * Apply a `set` directive: store/refresh a slot's snapshot.
+   * @param {object} directive `{ snapshot_frame }`
+   */
+  function applySet(directive) {
+    var frame = directive.snapshot_frame;
+    if (!frame || !frame.content) return;
+    var snapshot;
+    try { snapshot = JSON.parse(frame.content); } catch (e) { return; }
+    var type = snapshot.slot && snapshot.slot.type;
+    if (!type) return;
+    var entry = { frame: frame, snapshot: snapshot };
+    store.set(type, entry);
+    persistEntry(type, entry);
+    notify(type, frame, snapshot);
+    var scope = snapshot.slot && snapshot.slot.scope;
+    broadcast({ kind: 'set', frame: frame }, scope);
+  }
+
+  /**
+   * Apply a `clear` directive. The directive carries a slot key, so the type
+   * holding that key is resolved and removed.
+   * @param {object} directive `{ key }`
+   */
+  function applyClear(directive) {
+    var key = directive.key;
+    var typeToRemove = null;
+    store.forEach(function (entry, type) {
+      if (entry.snapshot.slot && entry.snapshot.slot.key === key) typeToRemove = type;
+    });
+    if (typeToRemove) clearType(typeToRemove, { broadcast: true });
+  }
+
+  // ---------------------------------------------------------------------------
+  // Snapshot freshness & request headers
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Categorize a held snapshot relative to the current time.
+   * @returns {'silent'|'always'|'invalid'|'stale'|'fresh'}
+   *   - `silent`: unidirectional (`transmit_after` undefined) - never sent.
+   *   - `always`: `transmit_after === as_at` - send the base64 frame each time.
+   *   - `invalid`: past `invalid_after` - drop and refetch.
+   *   - `stale`: past `transmit_after` but not yet invalid - pre-flight first.
+   *   - `fresh`: still cached server-side - send the `as_at` reference.
+   */
+  function entryCategory(entry) {
+    var snap = entry.snapshot;
+    var transmit = snap.transmit_after;
+    if (transmit === undefined || transmit === null) return 'silent';
+    if (transmit === snap.as_at) return 'always';
+    var now = Date.now();
+    var invalidAt = parseTime(snap.invalid_after);
+    var transmitAt = parseTime(transmit);
+    if (invalidAt !== undefined && now >= invalidAt) return 'invalid';
+    if (transmitAt !== undefined && now >= transmitAt) return 'stale';
+    return 'fresh';
+  }
+
+  /**
+   * Drop locally-invalid snapshots and pre-flight any stale ones. Pre-flighting
+   * posts the stale frames to the slot endpoint and applies the returned `set`
+   * directives, refreshing their `as_at` values before the real request runs.
+   *
+   * Single-flight: concurrent callers share one in-flight preparation.
+   * @returns {Promise<void>}
+   */
+  var preparePromise = null;
+  function prepareSlots() {
+    if (preparePromise) return preparePromise;
+    preparePromise = doPrepareSlots().then(function () {
+      preparePromise = null;
+    }, function () {
+      preparePromise = null;
+    });
+    return preparePromise;
+  }
+
+  async function doPrepareSlots() {
+    var stale = [];
+    store.forEach(function (entry, type) {
+      var cat = entryCategory(entry);
+      if (cat === 'invalid') {
+        clearType(type, { broadcast: false });
+      } else if (cat === 'stale') {
+        stale.push(entry.frame);
+      }
+    });
+    if (stale.length) {
+      var directives = await postSlots(stale);
+      applyDirectives(directives, { originElement: null });
+    }
+  }
+
+  /**
+   * Build the `X-Statum-Slot` header values for every held slot, using the
+   * freshness rules. Assumes {@link prepareSlots} has just run.
+   * @returns {string[]}
+   */
+  function buildSlotHeaders() {
+    var headers = [];
+    store.forEach(function (entry) {
+      var snap = entry.snapshot;
+      var cat = entryCategory(entry);
+      if (cat === 'silent' || cat === 'invalid') return;
+      if (cat === 'always') {
+        headers.push(b64encode(JSON.stringify(entry.frame)));
+      } else {
+        headers.push('key=' + snap.slot.key + '; as_at=' + snap.as_at);
+      }
+    });
+    return headers;
+  }
+
+  // ---------------------------------------------------------------------------
+  // HTTP helpers
+  // ---------------------------------------------------------------------------
+
+  /** True if a response is a Statum directive response. */
+  function isStatum(res) {
+    return (res.headers.get('Content-Type') || '').indexOf(STATUM_CONTENT_TYPE) !== -1;
+  }
+
+  /**
+   * POST an array of frames to the slot endpoint (the pre-flight target) and
+   * return its directive array. This request intentionally carries no slot
+   * headers (it is itself the refresh).
+   * @param {object[]} frames
+   * @returns {Promise<object[]>}
+   */
+  async function postSlots(frames) {
+    var headers = new Headers();
+    headers.set('Accept', STATUM_CONTENT_TYPE);
+    headers.set('Content-Type', 'application/json');
+    var res = await fetch(config.slotsUrl, {
+      method: 'POST',
+      headers: headers,
+      body: JSON.stringify(frames),
+      credentials: 'same-origin'
+    });
+    if (!isStatum(res)) return [];
+    try { return await res.json(); } catch (e) { return []; }
+  }
+
+  /**
+   * Apply the directives from a response. Set/clear run first, then `render`,
+   * then terminal navigation (`navigate`, then `post`). Returns any error
+   * directive encountered without throwing, so callers can decide.
+   * @param {object[]} directives
+   * @param {{originElement?: HTMLElement}} [ctx]
+   * @returns {{error?: StatumError}}
+   */
+  function applyDirectives(directives, ctx) {
+    var result = { error: undefined };
+    var list = Array.isArray(directives) ? directives : [];
+    var navigateDirective = null;
+    var postDirective = null;
+
+    for (var i = 0; i < list.length; i++) {
+      var d = list[i];
+      if (!d || typeof d !== 'object') continue;
+      switch (d.type) {
+        case 'set': applySet(d); break;
+        case 'clear': applyClear(d); break;
+        case 'error':
+          result.error = new StatumError(d.message, d.code, d.data);
+          if (ctx && ctx.originElement) applyErrorClasses(ctx.originElement, true);
+          break;
+        case 'navigate': navigateDirective = d; break;
+        case 'post': postDirective = d; break;
+      }
+    }
+
+    renderAll();
+
+    if (navigateDirective) doNavigate(navigateDirective);
+    if (postDirective) doPost(postDirective);
+    return result;
+  }
+
+  /** Execute a `navigate` directive as a full page load. */
+  function doNavigate(d) {
+    if (d.uri) window.location.href = d.uri;
+  }
+
+  /** Execute a `post` directive as a real (navigating) form POST. */
+  function doPost(d) {
+    if (!d.uri) return;
+    var form = document.createElement('form');
+    form.method = 'POST';
+    form.action = d.uri;
+    form.style.display = 'none';
+    var data = d.data || {};
+    Object.keys(data).forEach(function (name) {
+      var input = document.createElement('input');
+      input.type = 'hidden';
+      input.name = name;
+      input.value = data[name] == null ? '' : String(data[name]);
+      form.appendChild(input);
+    });
+    document.body.appendChild(form);
+    form.submit();
+  }
+
+  /**
+   * Handle a fetch response: if it is a Statum response, apply its directives;
+   * otherwise throw on HTTP errors and no-op on other successful responses.
+   * @param {Response} res
+   * @param {{originElement?: HTMLElement}} [ctx]
+   */
+  async function handleResponse(res, ctx) {
+    if (!isStatum(res)) {
+      if (!res.ok) throw new StatumError('HTTP ' + res.status);
+      return;
+    }
+    var directives;
+    try { directives = await res.json(); } catch (e) { directives = []; }
+    var result = applyDirectives(directives, ctx);
+    if (result.error) throw result.error;
+  }
+
+  // ---------------------------------------------------------------------------
+  // Entrypoint & action requests
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Fetch the entrypoint for the current URL, applying its directives. Carries
+   * slot headers (and pre-flights) like any other request.
+   * @returns {Promise<void>}
+   */
+  async function loadEntrypoint() {
+    await prepareSlots();
+    var headers = new Headers();
+    headers.set('Accept', STATUM_CONTENT_TYPE);
+    buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); });
+    var url = config.entrypointUrl + '?uri=' + encodeURIComponent(window.location.href);
+    var res = await fetch(url, { headers: headers, credentials: 'same-origin' });
+    await handleResponse(res, { originElement: null });
+  }
+
+  /**
+   * Perform an action request.
+   *
+   * @param {object} action `{ uri, method, private }`
+   * @param {object} [data] Key/value parameters. Encoded as query string for
+   *   GET/HEAD, otherwise as `application/x-www-form-urlencoded`.
+   * @param {HTMLElement} [originElement] Element that triggered the action, so
+   *   that `stm-on-error` classes can be applied on an `error` directive.
+   * @returns {Promise<void>} Resolves once the request completes and its
+   *   directives have run; rejects on HTTP failure or an `error` directive.
+   */
+  async function performAction(action, data, originElement) {
+    await prepareSlots();
+    var headers = new Headers();
+    headers.set('Accept', STATUM_CONTENT_TYPE);
+    buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); });
+    if (action.private) headers.set(PRIVATE_HEADER, action.private);
+
+    var method = (action.method || 'GET').toUpperCase();
+    var params = data || {};
+    var init = { method: method, headers: headers, credentials: 'same-origin' };
+    var url = action.uri;
+
+    if (method === 'GET' || method === 'HEAD') {
+      var qs = new URLSearchParams(params).toString();
+      if (qs) url += (url.indexOf('?') !== -1 ? '&' : '?') + qs;
+    } else {
+      init.body = new URLSearchParams(params).toString();
+      headers.set('Content-Type', 'application/x-www-form-urlencoded');
+    }
+
+    var res = await fetch(url, init);
+    await handleResponse(res, { originElement: originElement });
+  }
+
+  // ---------------------------------------------------------------------------
+  // Rendering: loops, conditionals, bindings
+  // ---------------------------------------------------------------------------
+
+  /** Templates captured from loop holders. @type {WeakMap<Element, Element>} */
+  var templates = new WeakMap();
+  /** Instance nodes managed by a loop holder. @type {WeakMap<Element, Element[]>} */
+  var instancesByHolder = new WeakMap();
+  /** Loop instance nodes; skipped by the render walk (owned by their holder). */
+  var instanceNodes = new WeakSet();
+  /** If-chain anchors for detached branches. @type {WeakMap<Element, Comment>} */
+  var ifAnchors = new WeakMap();
+  /** Elements whose `stm-action` has been wired, to avoid double-binding. */
+  var wired = new WeakSet();
+
+  /**
+   * Find a `stm-for-{name}-in` attribute on an element.
+   * @returns {{varName: string, expr: string}|null}
+   */
+  function getStmFor(el) {
+    var attrs = el.attributes;
+    for (var i = 0; i < attrs.length; i++) {
+      var match = attrs[i].name.match(/^stm-for-(.+)-in$/);
+      if (match) return { varName: match[1], expr: attrs[i].value };
+    }
+    return null;
+  }
+
+  /** Remove any `stm-for-*` and `stm-key` attributes from an element. */
+  function stripLoopAttrs(el) {
+    var toRemove = [];
+    for (var i = 0; i < el.attributes.length; i++) {
+      var name = el.attributes[i].name;
+      if (/^stm-for-(.+)-in$/.test(name) || name === 'stm-key') toRemove.push(name);
+    }
+    toRemove.forEach(function (n) { el.removeAttribute(n); });
+  }
+
+  /** Ensure an element is in the DOM (reattaching at its remembered anchor). */
+  function ensureAttached(el) {
+    var anchor = ifAnchors.get(el);
+    if (anchor && anchor.parentNode) {
+      anchor.parentNode.insertBefore(el, anchor);
+      anchor.parentNode.removeChild(anchor);
+      ifAnchors.delete(el);
+    }
+  }
+
+  /** Detach an element, remembering its position with a comment anchor. */
+  function ensureDetached(el) {
+    if (!el.parentNode) return;
+    if (!ifAnchors.has(el)) {
+      var anchor = document.createComment('#stm-if');
+      el.parentNode.insertBefore(anchor, el);
+      ifAnchors.set(el, anchor);
+    }
+    el.parentNode.removeChild(el);
+  }
+
+  /**
+   * Evaluate an `stm-if` / `stm-else-if` / `stm-else` chain beginning at `el`,
+   * attaching the matching branch and detaching the rest.
+   * @returns {Element|null} The active branch element (or `null`).
+   */
+  function handleIfChain(el, scope) {
+    var branches = [{ el: el, expr: el.getAttribute('stm-if') }];
+    var cur = el.nextElementSibling;
+    while (cur) {
+      if (cur.hasAttribute('stm-else-if')) {
+        branches.push({ el: cur, expr: cur.getAttribute('stm-else-if') });
+        cur = cur.nextElementSibling;
+      } else if (cur.hasAttribute('stm-else')) {
+        branches.push({ el: cur, expr: null });
+        break;
+      } else {
+        break;
+      }
+    }
+    var activeEl = null;
+    for (var i = 0; i < branches.length; i++) {
+      var b = branches[i];
+      if (b.expr === null || !!evalExpr(b.expr, scope)) { activeEl = b.el; break; }
+    }
+    branches.forEach(function (b) {
+      if (b.el === activeEl) ensureAttached(b.el);
+      else ensureDetached(b.el);
+    });
+    return activeEl;
+  }
+
+  /** Toggle `stm-class.{name}` classes from their predicate expressions. */
+  function handleClass(el, scope) {
+    var attrs = el.attributes;
+    for (var i = 0; i < attrs.length; i++) {
+      var attr = attrs[i];
+      if (attr.name.indexOf('stm-class.') === 0) {
+        var cls = attr.name.slice('stm-class.'.length);
+        if (cls && !!evalExpr(attr.value, scope)) el.classList.add(cls);
+        else el.classList.remove(cls);
+      }
+    }
+  }
+
+  /**
+   * Bind `stm-text` / `stm-html`. Per the "leave original DOM content" policy,
+   * an unresolved value (`undefined`) leaves the element untouched; otherwise
+   * the value is stringified (`null` becomes the empty string).
+   */
+  function handleTextHtml(el, scope) {
+    if (el.hasAttribute('stm-text')) {
+      var text = evalExpr(el.getAttribute('stm-text'), scope);
+      if (text !== undefined) el.innerText = text === null ? '' : String(text);
+    }
+    if (el.hasAttribute('stm-html')) {
+      var html = evalExpr(el.getAttribute('stm-html'), scope);
+      if (html !== undefined) el.innerHTML = html === null ? '' : String(html);
+    }
+  }
+
+  /**
+   * Expand a loop holder: reconcile its instances against the current array.
+   * Keyed loops (`stm-key`) reuse DOM nodes for unchanged keys to preserve
+   * focus/selection/input state; unkeyed loops rebuild wholesale.
+   */
+  function handleLoop(el, scope) {
+    var info = getStmFor(el);
+    if (!info) return;
+
+    var template = templates.get(el);
+    if (!template) {
+      template = el.cloneNode(true);
+      stripLoopAttrs(template);
+      templates.set(el, template);
+    }
+
+    var raw = evalExpr(info.expr, scope);
+    if (raw === undefined) {
+      // No value for the source path yet: clear instances and restore the
+      // server-rendered holder content (progressive enhancement).
+      var prev = instancesByHolder.get(el) || [];
+      prev.forEach(function (n) { n.remove(); });
+      instancesByHolder.set(el, []);
+      if (el.getAttribute('data-stm-active') === '1') {
+        el.removeAttribute('data-stm-active');
+        el.removeAttribute('hidden');
+      }
+      return;
+    }
+    var arr = Array.isArray(raw) ? raw : [];
+
+    // Activate: hide the holder so only rendered instances are visible.
+    if (el.getAttribute('data-stm-active') !== '1') {
+      el.setAttribute('data-stm-active', '1');
+      el.setAttribute('hidden', '');
+    }
+
+    var keyExpr = el.getAttribute('stm-key');
+    var next = [];
+
+    if (keyExpr) {
+      var oldByKey = new Map();
+      (instancesByHolder.get(el) || []).forEach(function (n) {
+        if (n.__stmKey !== undefined) oldByKey.set(n.__stmKey, n);
+      });
+      var used = new Set();
+      arr.forEach(function (item) {
+        var child = childScope(scope, info.varName, item);
+        var key = evalExpr(keyExpr, child);
+        var node = (key !== undefined && !used.has(key)) ? oldByKey.get(key) : undefined;
+        if (node) {
+          used.add(key);
+          processElement(node, child);
+        } else {
+          node = template.cloneNode(true);
+          node.__stmKey = key;
+          instanceNodes.add(node);
+          processElement(node, child);
+        }
+        next.push(node);
+      });
+      oldByKey.forEach(function (n, key) { if (!used.has(key)) n.remove(); });
+    } else {
+      (instancesByHolder.get(el) || []).forEach(function (n) { n.remove(); });
+      arr.forEach(function (item) {
+        var child = childScope(scope, info.varName, item);
+        var node = template.cloneNode(true);
+        instanceNodes.add(node);
+        processElement(node, child);
+        next.push(node);
+      });
+    }
+
+    // Place instances in order, immediately after the holder.
+    var ref = el;
+    next.forEach(function (node) {
+      if (node !== ref.nextSibling) ref.parentNode.insertBefore(node, ref.nextSibling);
+      ref = node;
+    });
+    instancesByHolder.set(el, next);
+  }
+
+  /**
+   * Process one element's bindings (and recurse). Loop holders are handled by
+   * {@link handleLoop}; `stm-else-if` / `stm-else` are owned by their `stm-if`.
+   */
+  function processElement(el, scope) {
+    if (getStmFor(el)) { handleLoop(el, scope); return; }
+    if (el.hasAttribute('stm-else-if') || el.hasAttribute('stm-else')) return;
+    if (el.hasAttribute('stm-if')) {
+      var active = handleIfChain(el, scope);
+      if (active) processInner(active, scope);
+      return;
+    }
+    processInner(el, scope);
+  }
+
+  /** Bind the non-control-flow attributes and recurse into children. */
+  function processInner(el, scope) {
+    el.__stmScope = scope; // latest scope, used when an action fires
+    handleClass(el, scope);
+    handleTextHtml(el, scope);
+    wireAction(el);
+    render(el, scope);
+  }
+
+  /**
+   * Walk an element's children, processing each. Loop instance nodes are
+   * skipped here (they are owned and processed by their holder).
+   */
+  function render(root, scope) {
+    if (!root || !root.children) return;
+    // Snapshot the children: processing mutates the DOM (detaching branches,
+    // inserting loop instances) and a live HTMLCollection would shift indices.
+    var children = Array.prototype.slice.call(root.children);
+    for (var i = 0; i < children.length; i++) {
+      var el = children[i];
+      if (instanceNodes.has(el)) continue;
+      processElement(el, scope);
+    }
+  }
+
+  /** Re-render the whole document against the current state. */
+  function renderAll() {
+    try { render(document.body, stateScope()); }
+    catch (e) { console.error('[statum] render error', e); }
+  }
+
+  // ---------------------------------------------------------------------------
+  // HTML API: actions
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Collect data for an action trigger: `stm-data-{key}` attributes plus, for a
+   * form, its named inputs.
+   * @param {HTMLElement} el
+   * @returns {object}
+   */
+  function collectData(el) {
+    var data = {};
+    var attrs = el.attributes;
+    for (var i = 0; i < attrs.length; i++) {
+      var name = attrs[i].name;
+      if (name.indexOf('stm-data-') === 0) data[name.slice('stm-data-'.length)] = attrs[i].value;
+    }
+    if (el.tagName === 'FORM') {
+      var fd = new FormData(el);
+      fd.forEach(function (value, key) {
+        data[key] = value instanceof File ? value.name : String(value);
+      });
+    }
+    return data;
+  }
+
+  /**
+   * Toggle the busy state of an action element: disables the element and its
+   * descendant form controls and applies `stm-busy-class` classes while busy,
+   * restoring prior state when not.
+   */
+  function setBusyState(el, busy) {
+    var nodes = [el].concat(Array.prototype.slice.call(
+      el.querySelectorAll('button,input,select,textarea,fieldset')));
+    var busyClasses = (el.getAttribute('stm-busy-class') || '').split(/\s+/).filter(Boolean);
+    if (busy) {
+      el.__stmDisabled = [];
+      nodes.forEach(function (n) {
+        if (!('disabled' in n)) return;
+        el.__stmDisabled.push([n, n.disabled]);
+        n.disabled = true;
+      });
+      busyClasses.forEach(function (c) { el.classList.add(c); });
+    } else {
+      (el.__stmDisabled || []).forEach(function (pair) { pair[0].disabled = pair[1]; });
+      el.__stmDisabled = [];
+      busyClasses.forEach(function (c) { el.classList.remove(c); });
+    }
+  }
+
+  /** Apply or remove `stm-on-error` classes on an action element. */
+  function applyErrorClasses(el, on) {
+    if (!el || !el.hasAttribute || !el.hasAttribute('stm-on-error')) return;
+    var classes = (el.getAttribute('stm-on-error') || '').split(/\s+/).filter(Boolean);
+    classes.forEach(function (c) { on ? el.classList.add(c) : el.classList.remove(c); });
+  }
+
+  /**
+   * Wire an element's primary action once. The listener resolves the action
+   * object against the element's latest render scope, so loop instances bind to
+   * their own item.
+   */
+  function wireAction(el) {
+    if (!el.hasAttribute('stm-action') || wired.has(el)) return;
+    wired.add(el);
+    var eventName = el.tagName === 'FORM' ? 'submit' : 'click';
+    el.addEventListener(eventName, function (event) {
+      event.preventDefault();
+      runElementAction(el);
+    });
+  }
+
+  /**
+   * Resolve and perform the action attached to an element, managing busy and
+   * error-state classes around the request.
+   * @param {HTMLElement} el
+   */
+  async function runElementAction(el) {
+    var scope = el.__stmScope || stateScope();
+    var action = evalExpr(el.getAttribute('stm-action'), scope);
+    if (!action || typeof action.uri !== 'string') return;
+
+    applyErrorClasses(el, false); // clear error state on a new attempt
+    var data = collectData(el);
+    setBusyState(el, true);
+    try {
+      await performAction(action, data, el);
+    } catch (err) {
+      // stm-on-error is applied by the directive path for `error` directives;
+      // ensure it is also applied for other failures (network, HTTP).
+      applyErrorClasses(el, true);
+    } finally {
+      setBusyState(el, false);
+    }
+  }
+
+  // ---------------------------------------------------------------------------
+  // Boot visibility (stm-preloader / stm-content)
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Toggle pre/post-entrypoint visibility. While loading, `stm-preloader`
+   * elements are shown and `stm-content` elements hidden; once ready, the
+   * reverse. Uses the `hidden` attribute so it does not fight inline styles.
+   * @param {boolean} ready
+   */
+  function setBootState(ready) {
+    var preloaders = document.querySelectorAll('[stm-preloader]');
+    var contents = document.querySelectorAll('[stm-content]');
+    for (var i = 0; i < preloaders.length; i++) {
+      ready ? preloaders[i].setAttribute('hidden', '') : preloaders[i].removeAttribute('hidden');
+    }
+    for (var j = 0; j < contents.length; j++) {
+      ready ? contents[j].removeAttribute('hidden') : contents[j].setAttribute('hidden', '');
+    }
+  }
+
+  // ---------------------------------------------------------------------------
+  // Public state accessors
+  // ---------------------------------------------------------------------------
+
+  /** Build the scope used for expression evaluation: `{ typeName: public }`. */
+  function stateScope() {
+    var scope = {};
+    store.forEach(function (entry, type) { scope[type] = entry.snapshot.public; });
+    return scope;
+  }
+
+  /** Read a type's `public` data (what the attributes bind to). */
+  function read(type) {
+    var entry = store.get(type);
+    return entry ? entry.snapshot.public : undefined;
+  }
+
+  /** Read a type's full snapshot object. */
+  function getSnapshot(type) {
+    var entry = store.get(type);
+    return entry ? entry.snapshot : undefined;
+  }
+
+  /** Read the frame wrapping a type's snapshot. */
+  function getFrame(type) {
+    var entry = store.get(type);
+    return entry ? entry.frame : undefined;
+  }
+
+  /** Build an object of `{ typeName: public }` for every held type. */
+  function state() {
+    var result = {};
+    store.forEach(function (entry, type) { result[type] = entry.snapshot.public; });
+    return result;
+  }
+
+  /**
+   * Clear state. With a type argument, clears that type only; without, clears
+   * all held state.
+   * @param {string} [type]
+   */
+  function clear(type) {
+    if (type === undefined) {
+      Array.from(store.keys()).forEach(function (t) { clearType(t, { broadcast: true }); });
+    } else {
+      clearType(type, { broadcast: true });
+    }
+    renderAll();
+  }
+
+  /**
+   * Perform an action. `action` may be an action object or a string path that
+   * resolves to an action object within the current state.
+   * @param {object|string} action
+   * @param {object} [data]
+   * @returns {Promise<void>}
+   */
+  function act(action, data) {
+    var resolved = action;
+    if (typeof action === 'string') resolved = evalExpr(action, stateScope());
+    if (!resolved || typeof resolved.uri !== 'string') {
+      return Promise.reject(new StatumError('Invalid action'));
+    }
+    return performAction(resolved, data, null);
+  }
+
+  /** Attach a state listener for a type. */
+  function addStateListener(type, callback) {
+    if (typeof callback !== 'function') return;
+    if (!listeners.has(type)) listeners.set(type, new Set());
+    listeners.get(type).add(callback);
+  }
+
+  /** Remove a previously-attached state listener. */
+  function removeStateListener(type, callback) {
+    var set = listeners.get(type);
+    if (!set) return;
+    set.delete(callback);
+    if (set.size === 0) listeners.delete(type);
+  }
+
+  // ---------------------------------------------------------------------------
+  // Initialization
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Initialize Statum: read URL overrides, run the session-cookie check,
+   * hydrate persisted state, open the cross-tab channel, fetch the entrypoint,
+   * and render. Safe to call multiple times.
+   * @returns {Promise<void>}
+   */
+  async function init() {
+    if (initialized) return;
+    initialized = true;
+
+    var body = document.body;
+    if (body) {
+      if (body.hasAttribute('stm-entrypoint')) config.entrypointUrl = body.getAttribute('stm-entrypoint');
+      if (body.hasAttribute('stm-slots')) config.slotsUrl = body.getAttribute('stm-slots');
+    }
+
+    try { initSession(); } catch (e) {}
+    try { hydrate(); } catch (e) {}
+    setupChannel();
+
+    setBootState(false);
+    renderAll(); // bind against any persisted/hydrated state immediately
+
+    try {
+      await loadEntrypoint();
+    } catch (err) {
+      console.error('[statum] entrypoint load failed', err);
+    } finally {
+      setBootState(true);
+    }
+  }
+
+  function autoInit() {
+    if (document.readyState === 'loading') {
+      document.addEventListener('DOMContentLoaded', init);
+    } else {
+      init();
+    }
+  }
+
+  // ---------------------------------------------------------------------------
+  // Public API
+  // ---------------------------------------------------------------------------
+
+  /** @namespace statum */
+  window.statum = {
+    /** Library version. */
+    version: '0.1.0',
+    /** Mutable endpoint configuration. */
+    config: config,
+    /** (Re)initialize the library. Usually runs automatically on load. */
+    init: init,
+    /** Read a type's `public` data. */
+    read: read,
+    /** Read a type's full snapshot object. */
+    getSnapshot: getSnapshot,
+    /** Read the frame wrapping a type's snapshot. */
+    getFrame: getFrame,
+    /** Build `{ typeName: public }` for every held type. */
+    state: state,
+    /** Clear one type (`clear("x")`) or all state (`clear()`). */
+    clear: clear,
+    /** Perform an action object or named action. */
+    act: act,
+    /** Attach a state-changed listener for a type. */
+    addStateListener: addStateListener,
+    /** Remove a previously-attached listener. */
+    removeStateListener: removeStateListener
+  };
+
+  autoInit();
+})();