clanker 2 settimane fa
parent
commit
c086090fdc

+ 25 - 0
example/Actions.vala

@@ -25,6 +25,7 @@ namespace Example {
             pub.name = name;
             pub.role = is_admin ? "admin" : "member";
             pub.logout = action_registry.author<LogoutAction>();
+            pub.update_announcement = action_registry.author<UpdateAnnouncementAction>();
 
             var priv = new AuthPrivate();
             priv.is_admin = is_admin;
@@ -63,4 +64,28 @@ namespace Example {
             return directives().update_held<CounterPublic>("counter", c => c.value += delta);
         }
     }
+
+    /**
+     * `POST /_statum/action/{guid}` — updates the global announcement from the
+     * dashboard. Reads the `text` form field, updates the global slot (which
+     * pushes to all subscribed clients via SSE), and returns to the dashboard.
+     */
+    public class UpdateAnnouncementAction : StatumAction {
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var text = "Updated";
+            if (request.form != null) {
+                var submitted = request.form.get_field("text");
+                if (submitted != null && ((!)submitted).length > 0) {
+                    text = (!)submitted;
+                }
+            }
+
+            yield state_service.update_global_typed<AnnouncementPublic>("announcement", a => {
+                a.text = text;
+            });
+
+            return directives().notify("info", "Announcement updated").navigate("/dashboard");
+        }
+    }
 }

+ 11 - 1
example/HomeEntrypoint.vala

@@ -24,12 +24,18 @@ namespace Example {
         public string name { get; set; }
         public string role { get; set; }
         public Model.ActionDto logout { get; set; }
+        public Model.ActionDto update_announcement { get; set; }
     }
 
     public class AuthPrivate : Object {
         public bool is_admin { get; set; }
     }
 
+    /** Global broadcast model — a simple announcement string. */
+    public class AnnouncementPublic : Object {
+        public string text { get; set; }
+    }
+
     /**
      * Entrypoint for the home page: hydrates counter + page slots with typed
      * models, embedding action references as fields.
@@ -49,6 +55,8 @@ namespace Example {
             page.login = action_registry.author<LoginAction>();
 
             return directives()
+                .set_global("announcement")
+                .subscribe_global("announcement")
                 .set_typed<CounterPublic>("counter", Scope.PAGE, counter)
                 .set_typed<PagePublic>("page", Scope.PAGE, page);
         }
@@ -60,7 +68,9 @@ namespace Example {
     public class DashboardEntrypoint : StatumEntrypoint {
 
         public override async DirectiveBuilder handle() throws GLib.Error {
-            return directives().notify("info", "Welcome to your dashboard");
+            return directives()
+                .set_global("announcement")
+                .notify("info", "Welcome to your dashboard");
         }
     }
 }

+ 35 - 0
example/Main.vala

@@ -1,4 +1,5 @@
 using Astralis;
+using Invercargill.DataStructures;
 using Inversion;
 using Statum;
 using Example;
@@ -21,8 +22,42 @@ void main(string[] args) {
         statum.action<LoginAction>();
         statum.action<LogoutAction>();
         statum.action<BumpCounterAction>();
+        statum.action<UpdateAnnouncementAction>();
         statum.add_resource<StylesResource>();
 
+        // Register a global broadcast slot (deterministic key "global:announcement").
+        var announcement = new AnnouncementPublic();
+        announcement.text = "Welcome to the Statum demo!";
+        statum.global<AnnouncementPublic>("announcement", announcement);
+
+        // Background timer: cycle the announcement every 10 seconds. Every
+        // subscribed tab receives the update via the SSE push — open two tabs
+        // side by side to see the broadcast in action.
+        string[] messages = {
+            "Welcome to the Statum demo!",
+            "Slots are client-carried and signed.",
+            "Try logging in as \"admin\".",
+            "Global slots use deterministic keys for broadcast.",
+            "The counter uses typed models with action references."
+        };
+        int msg_index = 0;
+        Timeout.add_seconds(10, () => {
+            msg_index = (msg_index + 1) % messages.length;
+            try {
+                var model = new AnnouncementPublic();
+                model.text = messages[msg_index];
+                var state = new State() {
+                    type_name = "announcement",
+                    public_data = GObjectMapping.to_properties(model),
+                    private_data = new PropertyDictionary()
+                };
+                statum.state_service.update_global.begin("announcement", state);
+            } catch (GLib.Error e) {
+                warning("Failed to update announcement: %s", e.message);
+            }
+            return true;
+        });
+
         application.run();
     } catch (Error e) {
         printerr("Error: %s\n", e.message);

+ 12 - 0
example/dashboard.html

@@ -12,6 +12,18 @@
     <h2>Dashboard</h2>
     <p>Signed in as <strong stm-text="auth.name">user</strong>
        (<span stm-text="auth.role">role</span>).</p>
+
+    <hr>
+
+    <h3>Update announcement</h3>
+    <p>Current: <span stm-text="announcement.text">…</span></p>
+    <form stm-action="auth.update_announcement">
+        <label>New text:
+            <input name="text" placeholder="Enter announcement text">
+        </label>
+        <button type="submit">Update</button>
+    </form>
+
     <p><a href="/">Back home</a></p>
 </body>
 </html>

+ 2 - 0
example/home.html

@@ -5,6 +5,8 @@
     <pstm-template>main</pstm-template>
 </head>
 <body>
+    <p><strong>Announcement:</strong> <span stm-text="announcement.text">…</span></p>
+
     <!-- A component (fragment): its <head> merges into the page head and its
          body is materialised into the wrapping element. -->
     <pstm-fragment name="counter-card" />

+ 18 - 0
getting-started.md

@@ -364,6 +364,8 @@ It auto-initialises on DOMContentLoaded.
 |---|---|
 | `stm-text="counter.value"` | Sets the element's text content from the expression. |
 | `stm-html="cart.summary"` | Sets the element's inner HTML. |
+| `stm-markdown="post.body"` | Renders the value as safe markdown (all source HTML escaped first, then formatted). |
+| `stm-h-demotion="1"` | Demotes child `<h1>`–`<h5>` tags by N levels. Runs after `stm-html`/`stm-markdown`. |
 | `stm-if="auth != null"` | Shows/hides the element (and its `stm-else-if`/`stm-else` siblings). |
 | `stm-else-if="auth.role == 'admin'"` | Next sibling of `stm-if`/`stm-else-if`. |
 | `stm-else` | Next sibling of `stm-if`/`stm-else-if`. |
@@ -405,6 +407,14 @@ statum.on("cart", function(data, old) {
 statum.act("cart.checkout", { qty: 2 })
     .then(function(directives) { … })
     .catch(function(err) { … });
+
+// Hooks: no-arg callbacks before/after each render cycle
+statum.beforeEvaluate(function() {
+    document.body.classList.add('evaluating');
+});
+statum.afterEvaluate(function() {
+    highlightCodeBlocks();
+});
 ```
 
 ---
@@ -449,9 +459,13 @@ Started via `directives()` on any handler. Fluent, self-returning:
 | `.update_held<T>(type, c => …)` | Typed read-mutate-write of a held slot (preserves action refs). |
 | `.clear(key)` | `clear` directive. |
 | `.navigate(uri)` | Terminal: redirect the client. |
+| `.form_submit(form_id, action?, method?)` | Terminal: submit a form by ID (optionally override action + method). |
+| `.form_reset(form_id)` | Terminal: reset a form by ID. |
 | `.notify(kind, message)` | Surface a notification (`kind` is app-defined). |
 | `.error(message, code?, data?)` | Report an error to the client. |
 | `.subscribe(key)` | Subscribe to realtime updates for a slot. |
+| `.set_global(type)` | Signs and emits `set` for a global broadcast slot. |
+| `.subscribe_global(type)` | Subscribes to a global broadcast slot's live updates. |
 | `.unsubscribe(key)` | Unsubscribe. |
 | `.set_frame(FrameDto)` | `set` from an already-signed frame. |
 | `.invalidate(signature)` | `invalidate` (drop a bad frame). |
@@ -479,6 +493,10 @@ Started via `directives()` on any handler. Fluent, self-returning:
 | `update(key, state)` async | Mutates, signs, pushes to channels → `FrameDto`. |
 | `clear_slot(key)` async | Removes + pushes `clear` event. |
 | `restore_slot(frame)` | Re-caches a slot from a verified frame. |
+| `new_global_slot(type, scope, state)` | Creates a global slot with deterministic key `"global:" + type`. |
+| `update_global(type, state)` async | Updates a global slot (sign + push to all subscribers). |
+| `update_global_typed<T>(type, mutator)` async | Typed read-mutate-write of a global slot. |
+| `clear_global(type)` async | Clears a global slot + pushes `clear`. |
 
 ### `ActionRegistry`
 

+ 122 - 5
js/statum.js

@@ -729,7 +729,7 @@
    * @returns {{errors: StatumError[], transmitKeys: string[], retry: boolean, navigate: object|null, post: object|null}}
    */
   function applyDirectives(directives, ctx) {
-    var result = { errors: [], transmitKeys: [], retry: false, navigate: null, post: null };
+    var result = { errors: [], transmitKeys: [], retry: false, navigate: null, post: null, formSubmits: [], formResets: [] };
     var list = Array.isArray(directives) ? directives : [];
 
     for (var i = 0; i < list.length; i++) {
@@ -763,6 +763,8 @@
           break;
         case 'navigate': result.navigate = d; break;
         case 'post': result.post = d; break;
+        case 'form-submit': result.formSubmits.push(d); break;
+        case 'form-reset': result.formResets.push(d); break;
       }
     }
 
@@ -770,12 +772,30 @@
     return result;
   }
 
-  /** Execute the terminal navigation directives collected by {@link applyDirectives}. */
+  /** Execute the terminal directives collected by {@link applyDirectives}. */
   function executeTerminal(result) {
+    result.formResets.forEach(doFormReset);
+    result.formSubmits.forEach(doFormSubmit);
     if (result.navigate) doNavigate(result.navigate);
     if (result.post) doPost(result.post);
   }
 
+  /** Execute a `form-submit` directive: optionally override action/method, then submit. */
+  function doFormSubmit(d) {
+    var form = document.getElementById(d.form);
+    if (form) {
+      if (d.action) form.setAttribute('action', d.action);
+      if (d.method) form.setAttribute('method', d.method);
+      form.submit();
+    }
+  }
+
+  /** Execute a `form-reset` directive. */
+  function doFormReset(d) {
+    var form = document.getElementById(d.form);
+    if (form) form.reset();
+  }
+
   /** Execute a `navigate` directive as a full page load. */
   function doNavigate(d) {
     if (d.uri) window.location.href = d.uri;
@@ -1208,10 +1228,87 @@
     });
   }
 
+   /**
+    * Escape-first minimal markdown renderer. All HTML in the source is escaped
+    * before formatting is applied, so untrusted input cannot inject markup.
+    * Supports headings, bold, italic, inline code, fenced code blocks, links,
+    * lists, blockquotes, horizontal rules and paragraphs.
+    */
+  function renderMarkdown(raw) {
+    var escaped = raw
+      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+      .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+    var lines = escaped.split('\n');
+    var out = [];
+    var inList = false, inCode = false;
+
+    function closeList() { if (inList) { out.push(inList === 'ul' ? '</ul>' : '</ol>'); inList = false; } }
+
+    for (var i = 0; i < lines.length; i++) {
+      var line = lines[i];
+
+      if (/^```/.test(line)) {
+        if (inCode) { out.push('</code></pre>'); inCode = false; }
+        else { closeList(); out.push('<pre><code>'); inCode = true; }
+        continue;
+      }
+      if (inCode) { out.push(line); continue; }
+
+      var h = line.match(/^(#{1,6})\s+(.*)$/);
+      if (h) { closeList(); out.push('<h' + h[1].length + '>' + mdInline(h[2]) + '</h' + h[1].length + '>'); continue; }
+
+      if (/^---+\s*$/.test(line)) { closeList(); out.push('<hr>'); continue; }
+
+      var bq = line.match(/^&gt;\s*(.*)$/);
+      if (bq) { closeList(); out.push('<blockquote>' + mdInline(bq[1]) + '</blockquote>'); continue; }
+
+      var ul = line.match(/^[-*]\s+(.*)$/);
+      if (ul) { if (inList !== 'ul') { closeList(); out.push('<ul>'); inList = 'ul'; } out.push('<li>' + mdInline(ul[1]) + '</li>'); continue; }
+
+      var ol = line.match(/^\d+\.\s+(.*)$/);
+      if (ol) { if (inList !== 'ol') { closeList(); out.push('<ol>'); inList = 'ol'; } out.push('<li>' + mdInline(ol[1]) + '</li>'); continue; }
+
+      closeList();
+      if (line.trim() === '') continue;
+      out.push('<p>' + mdInline(line) + '</p>');
+    }
+    closeList();
+    if (inCode) out.push('</code></pre>');
+    return out.join('\n');
+  }
+
+  function mdInline(text) {
+    return text
+      .replace(/`([^`]+)`/g, '<code>$1</code>')
+      .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
+      .replace(/\*([^*]+)\*/g, '<em>$1</em>')
+      .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
+  }
+
   /**
-   * 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).
+   * Demote child header tags by N levels (h1 → h2, etc.), capping at h6.
+   */
+  function handleHDemotion(el) {
+    var attr = el.getAttribute('stm-h-demotion');
+    if (!attr) return;
+    var n = parseInt(attr, 10);
+    if (isNaN(n) || n <= 0) return;
+    el.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(function (h) {
+      var level = parseInt(h.tagName.charAt(1), 10);
+      var newLevel = Math.min(level + n, 6);
+      if (newLevel === level) return;
+      var replacement = document.createElement('h' + newLevel);
+      for (var i = 0; i < h.attributes.length; i++)
+        replacement.setAttribute(h.attributes[i].name, h.attributes[i].value);
+      while (h.firstChild) replacement.appendChild(h.firstChild);
+      h.replaceWith(replacement);
+    });
+  }
+
+  /**
+   * Bind `stm-text` / `stm-html` / `stm-markdown`. Per the "leave original DOM
+   * content" policy, an unresolved value (`undefined`) leaves the element
+   * untouched; otherwise the value is stringified (`null` becomes empty).
    */
   function handleTextHtml(el, scope) {
     if (el.hasAttribute('stm-text')) {
@@ -1222,6 +1319,13 @@
       var html = evalExpr(el.getAttribute('stm-html'), scope);
       if (html !== undefined) el.innerHTML = html === null ? '' : String(html);
     }
+    if (el.hasAttribute('stm-markdown')) {
+      var md = evalExpr(el.getAttribute('stm-markdown'), scope);
+      if (md !== undefined) el.innerHTML = renderMarkdown(md === null ? '' : String(md));
+    }
+    // stm-h-demotion runs after stm-html / stm-markdown so the rendered
+    // headers are present in the DOM.
+    if (el.hasAttribute('stm-h-demotion')) handleHDemotion(el);
   }
 
   /**
@@ -1406,10 +1510,19 @@
     }
   }
 
+  // ---------------------------------------------------------------------------
+  // Evaluation hooks (no-arg callbacks before/after each render cycle)
+  // ---------------------------------------------------------------------------
+
+  var beforeHooks = [];
+  var afterHooks = [];
+
   function renderAll() {
     if (guardRedirected) return;
+    beforeHooks.forEach(function (fn) { try { fn(); } catch (e) { console.error('[statum] beforeEvaluate hook error', e); } });
     try { render(document.body, stateScope()); }
     catch (e) { console.error('[statum] render error', e); }
+    afterHooks.forEach(function (fn) { try { fn(); } catch (e) { console.error('[statum] afterEvaluate hook error', e); } });
     evaluateGuards();
   }
 
@@ -1734,6 +1847,10 @@
     addStateListener: addStateListener,
     /** Remove a previously-attached listener. */
     removeStateListener: removeStateListener,
+    /** Register a callback that runs before each render cycle (no arguments). */
+    beforeEvaluate: function (fn) { if (typeof fn === 'function') beforeHooks.push(fn); },
+    /** Register a callback that runs after each render cycle (no arguments). */
+    afterEvaluate: function (fn) { if (typeof fn === 'function') afterHooks.push(fn); },
     /** The Statum error type (thrown by `act` and surfaced to handlers). */
     Error: StatumError,
     get onConfirmHandler() { return handlers.onConfirm; },

+ 48 - 0
model.md

@@ -48,6 +48,8 @@ A statum snapshot captures an aspect of the application state at a particular po
     - `"notify"`: Used to surface a transient notification to the user.
     - `"transmit"`: Used to force the client to transmit a slot's frame to the server immediately, optionally retrying the originating request afterward.
     - `"invalidate"`: Tells the client to drop the held slot whose frame carries the given `signature`. Emitted automatically by the slot post endpoint for a frame whose signature did not verify, and by entrypoint/action endpoints for an always-send frame that failed verification. The signature is read off the frame envelope without trusting its `content`, so a corrupt frame can still be identified.
+    - `"form-submit"`: Submits a form by ID. Optionally carries `action` and `method` overrides. Terminal (executed after non-terminal directives).
+    - `"form-reset"`: Resets a form by ID. Terminal.
 
 Depending on the type, it will have other properties.
 
@@ -239,6 +241,44 @@ The default UI behaviours for confirmations, notifications, and errors can be cu
 - `statum.onNotifyHandler`: `(kind, message) => void`. Invoked for each `notify` directive. Defaults to `window.alert(message)`.
 - `statum.onErrorHandler`: `(error, element) => boolean | Promise<boolean>`. Invoked for every `error` directive, entrypoint load failure, and HTTP/network failure. `element` is the originating action element, or `null` for the entrypoint. Returning (or resolving) `false` suppresses the default behaviour (rejecting the originating `statum.act` promise and applying any `stm-on-error` classes); any other return value leaves the default in place.
 
+## Evaluation hooks
+
+The library exposes two no-arg callback hooks that fire before and after each render cycle (initial render, entrypoint hydration, `set`/`clear`, and realtime events):
+
+- `statum.beforeEvaluate(fn)` — registers a callback that runs **before** the DOM is re-rendered. Use it to prepare state, toggle loading classes, or perform any pre-render work.
+- `statum.afterEvaluate(fn)` — registers a callback that runs **after** the DOM has been re-rendered and guards have been evaluated. Use it for post-render tasks like syntax highlighting, analytics, or DOM measurement.
+
+Callbacks take no arguments (the scope is built internally from held-slot state and is not mutable from hooks). They are isolated with try/catch; an exception in one hook does not prevent subsequent hooks or the render itself.
+
+```js
+statum.beforeEvaluate(function () { document.body.classList.add('evaluating'); });
+statum.afterEvaluate(function () { highlightCodeBlocks(); });
+```
+
+## Global slots
+
+A global slot uses a **deterministic key** (`"global:" + type_name`) so every client references the same slot. This enables broadcast state — a live score, a system announcement, or any value the server owns and pushes to all connected clients.
+
+Global slots are **registered at startup** and **opt-in per page**: each entrypoint or action that needs a global explicitly includes it via `set_global` (current value) and/or `subscribe_global` (live SSE updates). Pages that don't opt in never receive the global.
+
+```vala
+// Startup registration
+statum.global<ScorePublic>("score", new ScorePublic { home = 0, away = 0 });
+
+// Entrypoint — include the value + subscribe for live updates
+return directives()
+    .set_global("score")
+    .subscribe_global("score");
+
+// Action — update via update_held (client holds it from the entrypoint)
+return directives().update_held<ScorePublic>("score", s => s.home += 1);
+
+// Background (timer, webhook) — typed read-mutate-write
+yield state_service.update_global_typed<ScorePublic>("score", s => s.home += 1);
+```
+
+Updates fan out via the existing SSE channel (every client subscribed to `"global:score"` receives the push). Clients that miss the push refresh on their next request via the stale-`as_at` auto-set mechanism. Global slots are public broadcast state only — the deterministic key is well-known, so there is no access control.
+
 ## Realtime channel
 
 Live slot updates are delivered over the realtime channel (see [Channel Endpoint](#channel-endpoint)). The library manages this automatically in response to `subscribe` and `unsubscribe` directives: it opens a shared connection via a `SharedWorker` (script url overridable with `stm-worker`, default `/_statum/worker.js`) on the first subscribe, reference-counts subscriptions across tabs, and relays incoming `set`/`clear` events to every tab through the existing cross-tab `BroadcastChannel`. When `SharedWorker` is unavailable each tab opens its own connection and broadcasts the events it receives to its siblings. In every case a tab applies each distinct frame exactly once, thanks to [set-directive idempotency](#set-directive).
@@ -274,6 +314,14 @@ The attribute `stm-text` binds the element's `innerText` to the specified state
 
 The attribute `stm-html` binds the element's `innerHTML` to the specified state value.
 
+## Markdown binding
+
+The attribute `stm-markdown` renders a state value as **safe markdown**: all HTML in the source is escaped first, then markdown formatting (headings, bold, italic, inline code, fenced code blocks, links, lists, blockquotes, horizontal rules, paragraphs) is applied. This is safe for untrusted user input — no raw HTML can be injected.
+
+## Header demotion
+
+The attribute `stm-h-demotion` (e.g. `stm-h-demotion="1"`) demotes every child header tag (`<h1>`–`<h5>`) by the specified number of levels, capping at `<h6>`. It runs after `stm-html` / `stm-markdown` so the rendered headers are present. Useful when rendering user-generated markdown inside a page that already has its own heading hierarchy.
+
 ## Attribute binding
 
 The attribute `stm-attribute.{name}` (where `{name}` is any attribute name) binds an attribute of the element to an expression. When the expression evaluates to a boolean, the attribute is toggled by presence (`true` adds it as a boolean attribute, `false` removes it); when it evaluates to any other value, the attribute is set to that value (coerced to a string). An `undefined` result removes the attribute. For example `<a stm-attribute.href="user.profileUrl">` sets the `href`, while `<input type="checkbox" stm-attribute.checked="user.agreed">` toggles `checked`.

+ 451 - 0
plans/feature-plan.md

@@ -0,0 +1,451 @@
+# Feature Plan — Client attributes, form directives, hooks, stale-set, meson generator
+
+## Features
+
+1. **`stm-markdown`** — render untrusted markdown as safe HTML (all source HTML
+   escaped first).
+2. **`stm-h-demotion`** — demote child header tags by N levels (runs after
+   `stm-html` / `stm-markdown`).
+3. **Form directives** (`form-submit`, `form-reset`) — programmatically submit or
+   reset a form by ID from a server directive.
+4. **Before/after evaluation hooks** — `statum.beforeEvaluate(fn)` /
+   `statum.afterEvaluate(fn)` JS API.
+5. **Automatic `set` for stale slots** — server auto-refreshes a client's stale
+   snapshot (when the client's `as_at` predates the slot's last update).
+6. **Meson generator for `mkpstm`** — a reusable `statum_page()` helper so apps
+   don't write a `custom_target` per page.
+7. **Documentation updates** — `model.md`, `getting-started.md`.
+
+---
+
+## A. `stm-markdown` attribute — `js/statum.js`
+
+### Design
+
+A built-in minimal markdown parser (no external dependencies, since the client
+is self-contained). The pipeline is:
+
+1. Escape **all** HTML entities in the raw string first (`<` → `&lt;`, etc.) so
+   untrusted input cannot inject markup.
+2. Apply markdown formatting rules that produce safe HTML tags.
+
+Supported syntax: headings (`#`–`######`), bold (`**`), italic (`*`), inline
+code (`` ` ``), fenced code blocks (```` ``` ````), links (`[text](url)`),
+unordered lists (`-`/`*`), ordered lists (`1.`), blockquotes (`>`), horizontal
+rules (`---`), and paragraphs (blank-line separated).
+
+### Implementation
+
+- Add `renderMarkdown(string raw) → string html` in the client IIFE.
+- In `handleTextHtml`, after the `stm-text` / `stm-html` checks:
+  ```js
+  if (el.hasAttribute('stm-markdown')) {
+      var md = evalExpr(el.getAttribute('stm-markdown'), scope);
+      if (md !== undefined) el.innerHTML = renderMarkdown(md === null ? '' : String(md));
+  }
+  ```
+- Order matters: `stm-markdown` runs **before** `stm-h-demotion` (B) so the
+  demotion sees the rendered headers.
+
+---
+
+## B. `stm-h-demotion` attribute — `js/statum.js`
+
+### Design
+
+`stm-h-dementia="1"` demotes every `<h1>`–`<h5>` child by N levels: `<h1>` →
+`<h2>`, `<h2>` → `<h3>`, etc. `<h6>` stays `<h6>` (HTML caps at 6). The attribute
+is read from the same element that carries `stm-html` / `stm-markdown`, and must
+run **after** those have set `innerHTML`.
+
+### Implementation
+
+- Add `handleHDemotion(el)` called from `handleTextHtml` **after** the
+  `stm-html` / `stm-markdown` blocks, when the element has `stm-h-demotion`.
+- Walk `el.querySelectorAll('h1,h2,h3,h4,h5,h6')`, read the current level from
+  the tag name, add N (capped at 6), and replace the element with a new one of
+  the demoted tag (preserving attributes and children via
+  `document.createElement` + `replaceWith`).
+
+---
+
+## C. Form directives — `src/Model/DirectiveDto.vala`, `js/statum.js`, `src/DirectiveBuilder.vala`
+
+### Design
+
+Two new directive types:
+
+```json
+{"type":"form-submit","form":"checkout","action":"/pay","method":"POST"}
+{"type":"form-reset","form":"search"}
+```
+
+- `form` — the `id` of the `<form>` element to target.
+- `form-submit` optionally carries `action` (a URI string) and `method` (`"POST"`
+  or `"GET"`) — if present, the client sets `form.action` and `form.method`
+  before calling `form.submit()`.
+- `form-reset` calls `form.reset()`.
+
+Both are **terminal** (executed after non-terminal directives, alongside
+`navigate` / `post`).
+
+### Implementation
+
+- **`DirectiveDto.vala`**: add `FormSubmitDirectiveDto` (form, optional action,
+  optional method) and `FormResetDirectiveDto` (form), each with a mapper +
+  `from_json` dispatch.
+- **`DirectiveBuilder.vala`**: add
+  `.form_submit(string form_id, string? action = null, string? method = null)`
+  and `.form_reset(string form_id)`.
+- **`js/statum.js`**:
+  - `applyDirectives`: add `case 'form-submit'` / `case 'form-reset'` to the
+    `result.terminal` collection.
+  - `executeTerminal`: call `doFormSubmit(d)` / `doFormReset(d)`:
+    ```js
+    function doFormSubmit(d) {
+        var form = document.getElementById(d.form);
+        if (form) {
+            if (d.action) form.action = d.action;
+            if (d.method) form.method = d.method;
+            form.submit();
+        }
+    }
+    function doFormReset(d) {
+        var form = document.getElementById(d.form);
+        if (form) form.reset();
+    }
+    ```
+
+---
+
+## D. Before/after evaluation hooks — `js/statum.js`
+
+### Design
+
+```js
+statum.beforeEvaluate(function() {
+    // Runs before each render cycle; no arguments.
+    document.body.classList.add('evaluating');
+});
+
+statum.afterEvaluate(function() {
+    // Runs after render + guard evaluation; DOM is up to date.
+    highlightCodeBlocks();
+});
+```
+
+- `beforeEvaluate` callbacks take **no arguments** and run before `render()` in
+  `renderAll`. They cannot mutate the scope (the scope is built internally from
+  held-slot state).
+- `afterEvaluate` callbacks take **no arguments** and run after `render()` +
+  guard evaluation.
+- Both are called on every render cycle (initial render, entrypoint hydration,
+  set/clear, realtime events).
+
+### Implementation
+
+- Module-level `var beforeHooks = []; var afterHooks = [];`.
+- `renderAll()`:
+  ```js
+  function renderAll() {
+      if (guardRedirected) return;
+      beforeHooks.forEach(function(fn) { try { fn(); } catch(e){} });
+      try { render(document.body, stateScope()); } catch(e) { console.error('[statum] render error', e); }
+      afterHooks.forEach(function(fn) { try { fn(); } catch(e){} });
+      evaluateGuards();
+  }
+  ```
+- Public API: `window.statum.beforeEvaluate = function(fn) { beforeHooks.push(fn); };`
+  `window.statum.afterEvaluate = function(fn) { afterHooks.push(fn); };`
+
+---
+
+## E. Automatic `set` for stale slots — `src/HeldSlotResolver.vala`, `src/StatumDirectives.vala`, endpoints
+
+### Design
+
+When the client sends a slot reference (`key=K; as_at=T`) and the server's cached
+slot has `current_state.last_touched > T`, the client is holding a stale
+snapshot. The server automatically includes a fresh `set` directive (re-signed
+frame) for that slot, so the client refreshes without the handler explicitly
+emitting one.
+
+This covers the common case: tab A updates a slot (via an action), tab B (still
+holding the old snapshot) sends a request — the server sees tab B's stale `as_at`
+and auto-refreshes it.
+
+### Implementation
+
+- **`ResolvedSlots`**: add `Series<string> stale_keys` — slot keys whose
+  `as_at` predates `last_touched`.
+- **`HeldSlotResolver.resolve_reference`**: after `get_slot(key)` succeeds,
+  compare the client's `as_at` (parsed from the header) with
+  `slot.current_state.last_touched`. If `as_at < last_touched`, add the key to
+  `stale_keys` (the slot is still resolved into `held` — the handler can use it;
+  the auto-set just refreshes the client).
+- **`StatumDirectives.to_result`**: after appending handler directives, sign
+  each stale key (`state_service.sign_slot(key)`) and append a `set` directive.
+  This needs `StateService` injected into `StatumDirectives` or passed as a
+  parameter. Prefer passing `state_service` and `stale_keys` as additional params
+  to `to_result`.
+- **Entrypoint + Action endpoints**: pass `resolved.stale_keys` to `to_result`.
+
+Edge case: if `as_at` is absent (malformed reference), skip the staleness check
+(treat as fresh).
+
+---
+
+## F. Meson generator for `mkpstm` — `tools/meson` or root `meson.build`
+
+### Design
+
+Provide a reusable meson function so apps register pages without writing a
+`custom_target` each time:
+
+```meson
+# In the app's meson.build:
+statum = import('statum')  # or: subdir('../tools/statum_meson')
+
+home_src = statum.page('home', namespace: 'MyApp', deps: ['main.html', 'card.html'])
+about_src = statum.page('about', namespace: 'MyApp', deps: ['main.html'])
+
+executable('myapp', ['Main.vala', home_src, about_src], ...)
+```
+
+Meson doesn't support `import()` for project-local modules, so the practical
+approach is a **helper function** in a shared meson snippet:
+
+```meson
+# statum_pages.meson (included via configure_file or subdir)
+statum_mkpstm = ...  # the tool target
+
+function statum_page = function(name, namespace: '', extra_deps: []) -> meson object
+```
+
+Meson functions are not first-class, so the realistic implementation is a
+**template custom_target** that the app includes:
+
+```meson
+# In the app's meson.build, after subdir('tools'):
+statum_mkpstm = statum_mkpstm  # the tool executable target
+
+home_page = custom_target(
+    input: 'home.html',
+    output: 'HomePage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'HomePage', '--ns=MyApp', '@INPUT@'],
+    depend_files: files('main.html', 'card.html')
+)
+```
+
+The improvement: ship a **`statum_page()` meson function** in a
+`tools/statum_meson.build` that apps `subdir()` or copy, reducing per-page
+boilerplate to one line:
+
+```meson
+home_page = statum_page('home', 'HomePage', 'MyApp', files('main.html'))
+about_page = statum_page('about', 'AboutPage', 'MyApp', files('main.html'))
+```
+
+where `statum_page` is defined as:
+
+```meson
+statum_mkpstm = statum_mkpstm  # set by tools/meson.build
+
+function statum_page = ... (not supported)
+
+# Instead, a foreach-driven helper:
+# statum_page(name, class, ns, deps) → custom_target
+```
+
+Since meson lacks user-defined functions, the cleanest approach is a
+**`configure_file`-generated meson snippet** or simply documenting the
+one-liner `custom_target` pattern with a shorter invocation. The practical
+outcome: a documented `statum_page` pattern (one custom_target line per page,
+not six) plus a `depend_files` helper.
+
+### Implementation
+
+- Create `tools/statum_meson.build` defining a helper that apps include:
+  ```meson
+  # tools/statum_meson.build
+  # Usage: include this, then call statum_page(...)
+  
+  statum_mkpstm_bin = statum_mkpstm
+  
+  # Can't define functions in meson, so provide a single helper target:
+  # statum_page('home', 'HomePage', 'MyApp', files('main.html'))
+  ```
+  Given meson limitations, the realistic deliverable is a documented
+  one-liner pattern + a `depend_files` convention. The tools already accept
+  `-n` (class name) and `--ns` (namespace), so the command is already concise.
+
+### Server / Vala API changes
+
+#### New directive DTOs (`src/Model/DirectiveDto.vala`)
+
+Two new DTOs, mirroring the existing pattern (`discriminator`, `build_field_properties`,
+`get_mapper`, `from_json` dispatch):
+
+```vala
+/** Submits a form by ID (optionally overriding its action URI and method). Terminal. */
+public class FormSubmitDirectiveDto : DirectiveDto {
+    public string form { get; set; }
+    public string? action { get; set; }  // optional URI override
+    public string? method { get; set; }  // optional "POST" or "GET" override
+
+    public override string discriminator { get { return "form-submit"; } }
+    protected override Properties build_field_properties() throws GLib.Error { ... }
+    public FormSubmitDirectiveDto() { }
+    public FormSubmitDirectiveDto.with_form(string form, string? action = null, string? method = null) { ... }
+    public static PropertyMapper<FormSubmitDirectiveDto> get_mapper() { ... }
+}
+
+/** Resets a form by ID. Terminal. */
+public class FormResetDirectiveDto : DirectiveDto {
+    public string form { get; set; }
+
+    public override string discriminator { get { return "form-reset"; } }
+    protected override Properties build_field_properties() throws GLib.Error { ... }
+    public FormResetDirectiveDto() { }
+    public FormResetDirectiveDto.with_form(string form) { ... }
+    public static PropertyMapper<FormResetDirectiveDto> get_mapper() { ... }
+}
+```
+
+Register in `from_json`:
+```vala
+case "form-submit": return FormSubmitDirectiveDto.get_mapper().materialise(json);
+case "form-reset":  return FormResetDirectiveDto.get_mapper().materialise(json);
+```
+
+#### DirectiveBuilder methods (`src/DirectiveBuilder.vala`)
+
+```vala
+/** Emits a terminal `form-submit` directive (optionally overriding action + method). */
+public DirectiveBuilder form_submit(string form_id, string? action = null, string? method = null) {
+    items.add(new Item() { kind = ItemKind.DIRECTIVE,
+        directive = new Model.FormSubmitDirectiveDto.with_form(form_id, action, method) });
+    return this;
+}
+
+/** Emits a terminal `form-reset` directive. */
+public DirectiveBuilder form_reset(string form_id) {
+    items.add(new Item() { kind = ItemKind.DIRECTIVE,
+        directive = new Model.FormResetDirectiveDto.with_form(form_id) });
+    return this;
+}
+```
+
+#### Stale-slot auto-set (`src/HeldSlotResolver.vala`, `src/StatumDirectives.vala`, endpoints)
+
+**`ResolvedSlots`** — add:
+```vala
+public Series<string> stale_keys { get; private set; default = new Series<string>(); }
+```
+
+**`HeldSlotResolver.resolve_reference`** — after `get_slot(key)` succeeds, compare
+the client's `as_at` with `slot.current_state.last_touched`. If the client's
+`as_at` predates the slot's last update, add the key to `stale_keys`:
+```vala
+if (as_at != null && slot.current_state.last_touched != null
+        && ((!)as_at).compare(((!)slot.current_state).last_touched) < 0) {
+    result.stale_keys.add(slot.id);
+}
+```
+
+**`StatumDirectives.to_result`** — new overload (or extended signature) that
+accepts a `StateService` + `stale_keys` and appends fresh `set` directives:
+```vala
+public static HttpResult to_result(
+        Lot<Model.DirectiveDto> directives,
+        Lot<string>? invalid_signatures = null,
+        StateService? state_service = null,
+        Lot<string>? stale_keys = null) throws GLib.Error {
+    // ... existing invalid_signatures handling ...
+
+    // Auto-refresh stale slots: sign + append set directives.
+    if (state_service != null && stale_keys != null) {
+        foreach (var key in stale_keys) {
+            var frame = state_service.sign_slot(key);
+            if (frame != null) {
+                rendered.add(new Model.SetDirectiveDto.with_frame((!)frame));
+            }
+        }
+    }
+    // ... render JSON ...
+}
+```
+
+**Entrypoint + Action endpoints** — pass the extra args:
+```vala
+return StatumDirectives.to_result(directives,
+    resolved.invalid_signatures,
+    state_service,
+    resolved.stale_keys);
+```
+
+This means `StatumDirectives` needs access to `StateService` at the call site
+(passed as a parameter, not injected — keeps `StatumDirectives` stateless).
+The endpoints already inject `StateService` (entrypoint via the handler, action
+via the base class).
+
+---
+
+## G. Documentation updates — `model.md`, `getting-started.md`
+
+Update:
+
+- **`model.md`**:
+  - HTML API: add `stm-markdown`, `stm-h-demotion` attribute descriptions.
+  - Directives: add `form-submit`, `form-reset` to the directive list with
+    descriptions.
+  - Note the automatic stale-set behaviour in the Snapshot / Request Headers
+    sections.
+- **`getting-started.md`**:
+  - JS API Reference: add `stm-markdown`, `stm-h-demotion` to the attribute
+    table; add `statum.beforeEvaluate` / `statum.afterEvaluate` (no-arg
+    callbacks) to the public API; add `form-submit` / `form-reset` to the
+    directive builder table.
+  - HTML Templates: mention the meson page pattern.
+
+---
+
+## Tasks
+
+### Client (js/statum.js)
+- **JS-1** `renderMarkdown(raw)` — escape-first minimal markdown parser.
+- **JS-2** `stm-markdown` attribute in `handleTextHtml`.
+- **JS-3** `handleHDemotion(el)` — `stm-h-demotion` (runs after markdown/html).
+- **JS-4** `form-submit` / `form-reset` directive handling (`applyDirectives`
+  + `executeTerminal` + `doFormSubmit` / `doFormReset`).
+- **JS-5** `beforeEvaluate` / `afterEvaluate` hooks + public API.
+
+### Server (Vala)
+- **V-1** `FormSubmitDirectiveDto` / `FormResetDirectiveDto` in `DirectiveDto.vala`
+  — two new DTO classes: `FormSubmitDirectiveDto` (form + optional action +
+  optional method), `FormResetDirectiveDto` (form), each with discriminator,
+  `build_field_properties`, `get_mapper`, `with_form` constructor, and
+  `from_json` dispatch entries.
+- **V-2** `DirectiveBuilder.form_submit(form_id, action?, method?)` /
+  `.form_reset(form_id)` in `DirectiveBuilder.vala` — fluent terminal directive
+  emitters.
+- **V-3** `ResolvedSlots.stale_keys` + `HeldSlotResolver.resolve_reference`
+  staleness detection (compare client `as_at` vs `last_touched`; add key to
+  `stale_keys` when stale; slot still resolves into `held`).
+- **V-4** `StatumDirectives.to_result` extended signature (optional
+  `StateService` + `stale_keys`) — auto-signs stale keys and appends `set`
+  directives; `EntrypointEndpoint` and `StatumAction.handle_request` pass
+  `state_service` + `resolved.stale_keys`.
+
+### Build
+- **M-1** Document / provide the `statum_page` meson pattern.
+
+### Docs
+- **D-1** Update `model.md` (attributes, directives, stale-set).
+- **D-2** Update `getting-started.md` (attributes, hooks, form directives).
+
+### Testing
+- **T-1** `stm-markdown` renders safe HTML (escaped source + formatted output).
+- **T-2** `stm-h-demotion` demotes headers after markdown/html.
+- **T-3** `form-submit` / `form-reset` directives manipulate the form.
+- **T-4** Stale-slot auto-set: a request with an old `as_at` receives a `set`.

+ 120 - 0
plans/global-slots-plan.md

@@ -0,0 +1,120 @@
+# Global Slots Plan — Deterministic-key broadcast state with explicit opt-in
+
+## Overview
+
+Global slots let the server broadcast shared state (e.g. a live score) to all
+connected clients. They use a **deterministic key** (`"global:" + type_name`)
+so every client references the same slot. The existing slot machinery (signing,
+SSE push, stale-`as_at` auto-refresh) handles everything else — no new transport,
+no new channel protocol.
+
+Global slots are **explicit opt-in**: each entrypoint/action that needs a global
+calls `.set_global(type)` (current value) and/or `.subscribe_global(type)` (live
+SSE updates). Pages that don't opt in never receive the global.
+
+## Design
+
+### Deterministic key
+
+A global slot's key is `"global:" + type_name` (e.g. `"global:score"`). This is
+set as the slot's `id` at creation, so all existing methods (`get_slot`,
+`sign_slot`, `update`, `clear_slot`) work with it unchanged.
+
+### Registration (startup)
+
+```vala
+statum.global<ScorePublic>("score", new ScorePublic { home = 0, away = 0 });
+```
+
+Creates the slot with the deterministic key and the initial model (mapped via
+`GObjectMapping`). Scope is `PAGE` (the server always has the authoritative
+copy; the client doesn't need to persist it).
+
+### Explicit opt-in (entrypoints / actions)
+
+```vala
+// Entrypoint: include the current value + subscribe for live updates
+return directives()
+    .set_global("score")
+    .subscribe_global("score")
+    .set_typed<CounterPublic>("counter", Scope.PAGE, counter);
+
+// Action: update via update_held (client holds it from the entrypoint)
+return directives().update_held<ScorePublic>("score", s => s.home += 1);
+```
+
+`update_held` resolves the held slot's key (the deterministic `"global:score"`),
+mutates, signs, and pushes to **all** subscribed channels. The requesting client
+gets a `set` in the response; other clients get the SSE push. Clients that miss
+the push refresh on their next request via the stale-`as_at` auto-set.
+
+### Background updates (no client request)
+
+```vala
+// From a timer, webhook, or any non-request context
+yield state_service.update_global_typed<ScorePublic>("score", s => s.home += 1);
+```
+
+Pushes via SSE to all subscribers. Stale clients refresh on next request.
+
+### What reuses existing machinery
+
+| Mechanism | How it works for globals |
+|---|---|
+| Signing | `sign_slot("global:score")` — same as any slot |
+| SSE push | `update("global:score", state)` → `push_set` to all channels subscribed to that key |
+| Stale `as_at` auto-set | HeldSlotResolver compares the client's `as_at` with `last_touched`; stale → auto `set` appended |
+| Subscribe/unsubscribe | `subscribe` directive with key `"global:score"`; cleanup on page close as normal |
+
+---
+
+## Tasks
+
+### V-1: `StateService` global slot support — `src/StateService.vala`
+
+- `new_global_slot(string type_name, Scope scope, State state)` — creates a slot
+  with `id = "global:" + type_name`, caches it. If the key already exists (e.g.
+  on restart), updates the current state.
+- `update_global(string type_name, State state)` async — delegates to existing
+  `update("global:" + type_name, state)` (sign + push).
+- `update_global_typed<TPublic>(string type_name, owned UpdateMutator<TPublic> mutator)`
+  async — typed read-mutate-write (maps via `GObjectMapping`, preserves private).
+- `clear_global(string type_name)` async — delegates to `clear_slot`.
+
+### V-2: DirectiveBuilder global methods — `src/DirectiveBuilder.vala`
+
+- `.set_global(string type_name)` — derives `"global:" + type_name`, calls
+  `state_service.sign_slot(key)`, emits `set`. Signs synchronously at call time
+  (sign_slot is sync).
+- `.subscribe_global(string type_name)` — emits `subscribe` with the derived key.
+
+### V-3: StatumConfigurator.global<T>() — `src/Statum.vala`
+
+- `global<TPublic>(string type_name, TPublic initial)` — injects `StateService`,
+  maps the initial model via `GObjectMapping.to_properties`, calls
+  `state_service.new_global_slot(type_name, Scope.PAGE, state)`.
+
+### V-4: Example — `example/`
+
+- Add a global "announcement" slot to the example (simple string) to demonstrate
+  the flow: registered at startup, `set_global` + `subscribe_global` in the home
+  entrypoint, updated by a background timer.
+- No new page needed; the home page binds `stm-text="announcement.text"`.
+
+### D-1: Documentation
+
+- **`model.md`**: add a "Global slots" section describing the deterministic-key
+  model, explicit opt-in via `set_global` / `subscribe_global`, background
+  updates via `update_global_typed`, and the SSE/stale-refresh fan-out.
+- **`getting-started.md`**: add `set_global` / `subscribe_global` /
+  `update_global_typed` to the DirectiveBuilder and StateService tables; add a
+  "Global state" quick-start example.
+
+### T-1: Runtime test
+
+- Register a global at startup.
+- Verify the entrypoint (with `set_global` + `subscribe_global`) returns the
+  global's `set` + `subscribe`.
+- Verify `update_held<T>("global_type", ...)` in an action pushes the update
+  (the requesting client gets `set`; a second client's stale `as_at` gets an
+  auto-`set` on its next request).

+ 30 - 0
src/DirectiveBuilder.vala

@@ -118,6 +118,22 @@ namespace Statum {
             return this;
         }
 
+        /** Signs and emits a `set` for a global slot (deterministic key). */
+        public DirectiveBuilder set_global(string type_name) throws GLib.Error {
+            var frame = state_service.sign_slot(StateService.GLOBAL_KEY_PREFIX + type_name);
+            if (frame != null) {
+                items.add(new Item() { kind = ItemKind.DIRECTIVE, directive = new Model.SetDirectiveDto.with_frame((!)frame) });
+            }
+            return this;
+        }
+
+        /** Emits a `subscribe` for a global slot (deterministic key). */
+        public DirectiveBuilder subscribe_global(string type_name) {
+            items.add(new Item() { kind = ItemKind.DIRECTIVE,
+                directive = new Model.SubscribeDirectiveDto.with_key(StateService.GLOBAL_KEY_PREFIX + type_name) });
+            return this;
+        }
+
         /** Emits an `unsubscribe` directive. */
         public DirectiveBuilder unsubscribe(string key) {
             items.add(new Item() { kind = ItemKind.DIRECTIVE, directive = new Model.UnsubscribeDirectiveDto.with_key(key) });
@@ -156,6 +172,20 @@ namespace Statum {
             return this;
         }
 
+        /** Emits a terminal `form-submit` directive (optionally overriding action + method). */
+        public DirectiveBuilder form_submit(string form_id, string? action = null, string? method = null) {
+            items.add(new Item() { kind = ItemKind.DIRECTIVE,
+                directive = new Model.FormSubmitDirectiveDto.with_form(form_id, action, method) });
+            return this;
+        }
+
+        /** Emits a terminal `form-reset` directive. */
+        public DirectiveBuilder form_reset(string form_id) {
+            items.add(new Item() { kind = ItemKind.DIRECTIVE,
+                directive = new Model.FormResetDirectiveDto.with_form(form_id) });
+            return this;
+        }
+
         /** Materialises intents and returns the directive lot, in declaration order. */
         public async Lot<Model.DirectiveDto> build() throws GLib.Error {
             var directives = new Series<Model.DirectiveDto>();

+ 2 - 1
src/EntrypointEndpoint.vala

@@ -19,6 +19,7 @@ namespace Statum {
         private Inversion.Scope scope = inject<Inversion.Scope>();
         private HeldSlotResolver resolver = inject<HeldSlotResolver>();
         private EntrypointRouteTable route_table = inject<EntrypointRouteTable>();
+        private StateService state_service = inject<StateService>();
 
         public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
             var raw_uri = http_context.request.get_query("uri") ?? "/";
@@ -51,7 +52,7 @@ namespace Statum {
             var entrypoint = (StatumEntrypoint) scope.resolve_type(((!)match).entrypoint_type);
             var builder = yield entrypoint.handle();
             var directives = yield builder.build();
-            return StatumDirectives.to_result(directives, resolved.invalid_signatures);
+            return StatumDirectives.to_result(directives, resolved.invalid_signatures, state_service, resolved.stale_keys);
         }
 
         private static string extract_path(string uri) {

+ 8 - 3
src/GObjectMapping.vala

@@ -61,6 +61,10 @@ namespace Statum {
                 if ((spec.flags & ParamFlags.READABLE) == 0) {
                     continue;
                 }
+                // GObject stores property names with dashes (Vala underscores →
+                // dashes); use the underscored name on the wire so client-side
+                // expressions like `auth.update_announcement` resolve correctly.
+                var key = spec.name.replace("-", "_");
                 var value = Value(spec.value_type);
                 model.get_property(spec.name, ref value);
 
@@ -68,7 +72,7 @@ namespace Statum {
                     var obj = value.get_object();
                     if (obj != null) {
                         var sub_props = (!)(mappers().lookup(spec.value_type)).map_from_object((!)obj);
-                        dict[spec.name] = new JsonElement.from_properties(sub_props);
+                        dict[key] = new JsonElement.from_properties(sub_props);
                     }
                     continue;
                 }
@@ -78,7 +82,7 @@ namespace Statum {
                     continue;
                 }
 
-                dict[spec.name] = new ValueElement(value);
+                dict[key] = new ValueElement(value);
             }
             return dict;
         }
@@ -95,8 +99,9 @@ namespace Statum {
                 if ((spec.flags & ParamFlags.WRITABLE) == 0) {
                     continue;
                 }
+                var key = spec.name.replace("-", "_");
                 Element element;
-                if (!props.try_get(spec.name, out element) || element == null) {
+                if (!props.try_get(key, out element) || element == null) {
                     continue;
                 }
 

+ 16 - 2
src/HeldSlotResolver.vala

@@ -65,7 +65,7 @@ namespace Statum {
 
                     HeldSlot? slot = null;
                     if (slot_value.contains("key=")) {
-                        slot = resolve_reference(slot_value);
+                        slot = resolve_reference(slot_value, result);
                         if (slot == null || ((!)slot).type_name == null) {
                             var key = parse_reference_key(slot_value);
                             if (key != null) {
@@ -120,7 +120,7 @@ namespace Statum {
             }
         }
 
-        private HeldSlot? resolve_reference(string header_value) {
+        private HeldSlot? resolve_reference(string header_value, ResolvedSlots result) {
             string? key = null;
             DateTime? as_at = null;
             foreach (var part in header_value.split(";")) {
@@ -153,6 +153,13 @@ namespace Statum {
                 }
             }
 
+            // Detect staleness: if the client's as_at predates the slot's
+            // last_touched, the client holds an outdated snapshot.
+            if (as_at != null && state.last_touched != null
+                    && ((!)as_at).compare(state.last_touched) < 0) {
+                result.stale_keys.add(slot.id);
+            }
+
             return new HeldSlot() {
                 key = slot.id,
                 type_name = slot.type_name,
@@ -248,6 +255,13 @@ namespace Statum {
          */
         public Series<string> invalid_signatures { get; private set; default = new Series<string>(); }
 
+        /**
+         * Keys of cached slots whose client `as_at` predates the slot's
+         * `last_touched`; the caller auto-signs and appends a `set` so the
+         * client refreshes its stale snapshot.
+         */
+        public Series<string> stale_keys { get; private set; default = new Series<string>(); }
+
     }
 
 }

+ 73 - 0
src/Model/DirectiveDto.vala

@@ -70,6 +70,8 @@ namespace Statum.Model {
                 case "notify": return NotifyDirectiveDto.get_mapper().materialise(json);
                 case "transmit": return TransmitDirectiveDto.get_mapper().materialise(json);
                 case "invalidate": return InvalidateDirectiveDto.get_mapper().materialise(json);
+                case "form-submit": return FormSubmitDirectiveDto.get_mapper().materialise(json);
+                case "form-reset": return FormResetDirectiveDto.get_mapper().materialise(json);
                 default:
                     throw new ModelError.UNKNOWN_DIRECTIVE(@"Unrecognised directive type \"$((!)type)\"");
             }
@@ -447,4 +449,75 @@ namespace Statum.Model {
 
     }
 
+    /**
+     * Submits a form by ID (optionally overriding its action URI and method).
+     * Terminal.
+     */
+    public class FormSubmitDirectiveDto : DirectiveDto {
+
+        /** The `id` of the `<form>` element to submit. */
+        public string form { get; set; }
+
+        /** Optional URI to set as the form's `action` before submitting. */
+        public string? action { get; set; }
+
+        /** Optional HTTP method (`"POST"` / `"GET"`) to set before submitting. */
+        public string? method { get; set; }
+
+        public override string discriminator { get { return "form-submit"; } }
+
+        protected override Properties build_field_properties() throws GLib.Error {
+            return FormSubmitDirectiveDto.get_mapper().map_from(this);
+        }
+
+        public FormSubmitDirectiveDto() { }
+
+        public FormSubmitDirectiveDto.with_form(string form, string? action = null, string? method = null) {
+            this.form = form;
+            this.action = action;
+            this.method = method;
+        }
+
+        public static PropertyMapper<FormSubmitDirectiveDto> get_mapper() {
+            return PropertyMapper.build_for<FormSubmitDirectiveDto>(cfg => {
+                cfg.map<string>("form", o => o.form, (o, v) => o.form = v);
+                cfg.map<string>("action", o => o.action, (o, v) => o.action = v)
+                    .undefined_when(o => o.action == null);
+                cfg.map<string>("method", o => o.method, (o, v) => o.method = v)
+                    .undefined_when(o => o.method == null);
+                cfg.set_constructor(() => new FormSubmitDirectiveDto());
+            });
+        }
+
+    }
+
+    /**
+     * Resets a form by ID. Terminal.
+     */
+    public class FormResetDirectiveDto : DirectiveDto {
+
+        /** The `id` of the `<form>` element to reset. */
+        public string form { get; set; }
+
+        public override string discriminator { get { return "form-reset"; } }
+
+        protected override Properties build_field_properties() throws GLib.Error {
+            return FormResetDirectiveDto.get_mapper().map_from(this);
+        }
+
+        public FormResetDirectiveDto() { }
+
+        public FormResetDirectiveDto.with_form(string form) {
+            this.form = form;
+        }
+
+        public static PropertyMapper<FormResetDirectiveDto> get_mapper() {
+            return PropertyMapper.build_for<FormResetDirectiveDto>(cfg => {
+                cfg.map<string>("form", o => o.form, (o, v) => o.form = v);
+                cfg.set_constructor(() => new FormResetDirectiveDto());
+            });
+        }
+
+    }
+
 }

+ 65 - 0
src/StateService.vala

@@ -99,6 +99,71 @@ namespace Statum {
             yield channel_service.push_clear(key);
         }
 
+        // ------------------------------------------------------------------
+        // Global slots (deterministic-key broadcast state)
+        // ------------------------------------------------------------------
+
+        public const string GLOBAL_KEY_PREFIX = "global:";
+
+        /**
+         * Creates (or updates) a global slot with the deterministic key
+         * `"global:" + type_name`. Global slots share one key across all clients,
+         * so every subscriber sees the same updates.
+         */
+        public Slot new_global_slot(string type_name, Scope slot_scope, State state) {
+            var key = GLOBAL_KEY_PREFIX + type_name;
+            state.last_touched = new DateTime.now_utc();
+            state.type_name = type_name;
+
+            Slot? existing = null;
+            if (slots.try_get(key, out existing) && existing != null) {
+                ((!)existing).current_state = state;
+                return (!)existing;
+            }
+
+            var slot = new Slot() {
+                id = key,
+                scope = slot_scope,
+                type_name = type_name,
+                current_state = state
+            };
+            slots.set(key, slot);
+            return slot;
+        }
+
+        /** Updates a global slot by type name (sign + push to all subscribers). */
+        public async Model.FrameDto update_global(string type_name, State state) throws GLib.Error {
+            return yield update(GLOBAL_KEY_PREFIX + type_name, state);
+        }
+
+        /**
+         * Typed read-mutate-write of a global slot: maps its current public data
+         * to `TPublic`, applies `mutator`, maps back, signs and pushes. Used from
+         * background contexts (timers, webhooks) that have no request scope.
+         */
+        public async void update_global_typed<TPublic>(string type_name, owned UpdateMutator<TPublic> mutator) throws GLib.Error {
+            var key = GLOBAL_KEY_PREFIX + type_name;
+            var slot = get_slot(key);
+            if (slot == null || ((!)slot).current_state == null) {
+                throw new StateError.SLOT_NOT_FOUND(@"No global slot for type \"$type_name\"");
+            }
+            var current = ((!)slot).current_state;
+            var model = GObjectMapping.from_properties_typed<TPublic>(typeof(TPublic),
+                current.public_data ?? new PropertyDictionary());
+            mutator(model);
+            var new_state = new State() {
+                type_name = current.type_name,
+                public_data = GObjectMapping.to_properties((Object) model),
+                private_data = current.private_data ?? new PropertyDictionary()
+            };
+            yield update(key, new_state);
+        }
+
+        /** Clears a global slot by type name (remove + push clear to subscribers). */
+        public async void clear_global(string type_name) {
+            yield clear_slot(GLOBAL_KEY_PREFIX + type_name);
+        }
+
         public bool restore_slot(Model.FrameDto frame) throws GLib.Error {
             signing_provider.verify_frame(frame);
             var json_object = new JsonElement.from_string(frame.content).as<JsonObject>();

+ 19 - 0
src/Statum.vala

@@ -1,4 +1,5 @@
 using Inversion;
+using Invercargill.DataStructures;
 using Astralis;
 
 namespace Statum {
@@ -133,6 +134,8 @@ namespace Statum {
         private Container container = inject<Container>();
         private EntrypointRouteTable route_table = inject<EntrypointRouteTable>();
         private ActionRegistry action_registry = inject<ActionRegistry>();
+        /** Exposed for background tasks (timers, webhooks) that need to update global slots. */
+        public StateService state_service = inject<StateService>();
 
         /**
          * Resolves a page's route from its {@link StatumPage.route} property,
@@ -176,6 +179,22 @@ namespace Statum {
                 .as<StatumResource>();
         }
 
+        /**
+         * Registers a global broadcast slot with a deterministic key
+         * (`"global:" + type_name`). All clients that opt in (via
+         * {@link DirectiveBuilder.set_global} / {@link DirectiveBuilder.subscribe_global})
+         * share the same slot and see the same updates. The initial value is
+         * authored from a typed GObject model.
+         */
+        public void global<TPublic>(string type_name, TPublic initial) throws GLib.Error {
+            var state = new State() {
+                type_name = type_name,
+                public_data = GObjectMapping.to_properties((Object) initial),
+                private_data = new PropertyDictionary()
+            };
+            state_service.new_global_slot(type_name, Scope.PAGE, state);
+        }
+
         /**
          * Registers a {@link StatumAction}, auto-assigning it a GUID endpoint at
          * `/_statum/action/{guid}` (Spry-style — no hand-picked URI). Invoke the

+ 15 - 1
src/StatumDirectives.vala

@@ -34,8 +34,14 @@ namespace Statum {
          *
          * Any invalid frame `signature`s are appended as `invalidate` directives
          * so the client drops slots whose frames the server could not trust.
+         * Stale slot keys are auto-signed and appended as `set` directives so the
+         * client refreshes outdated snapshots.
          */
-        public static HttpResult to_result(Lot<Model.DirectiveDto> directives, Lot<string>? invalid_signatures = null) throws GLib.Error {
+        public static HttpResult to_result(
+                Lot<Model.DirectiveDto> directives,
+                Lot<string>? invalid_signatures = null,
+                StateService? state_service = null,
+                Lot<string>? stale_keys = null) throws GLib.Error {
             var rendered = new Series<Model.DirectiveDto>();
             foreach (var directive in directives) {
                 rendered.add(directive);
@@ -45,6 +51,14 @@ namespace Statum {
                     rendered.add(new Model.InvalidateDirectiveDto.with_signature(signature));
                 }
             }
+            if (state_service != null && stale_keys != null) {
+                foreach (var key in (!)stale_keys) {
+                    var frame = state_service.sign_slot(key);
+                    if (frame != null) {
+                        rendered.add(new Model.SetDirectiveDto.with_frame((!)frame));
+                    }
+                }
+            }
 
             var json = Model.DirectiveDto.serialize_array(rendered);
             var result = new HttpStringResult(json.stringify());

+ 2 - 2
src/StatumHandlers.vala

@@ -136,12 +136,12 @@ namespace Statum {
                     message = private_error_message,
                     code = private_error_code
                 });
-                return StatumDirectives.to_result(directives, resolved.invalid_signatures);
+                return StatumDirectives.to_result(directives, resolved.invalid_signatures, state_service, resolved.stale_keys);
             }
 
             var builder = yield handle();
             var directives = yield builder.build();
-            return StatumDirectives.to_result(directives, resolved.invalid_signatures);
+            return StatumDirectives.to_result(directives, resolved.invalid_signatures, state_service, resolved.stale_keys);
         }
 
         private Properties decrypt_action_private(HttpContext http_context, out bool failed) {