Unify actor responses on ui-response; rename ui-morph -> ui-update
One vocabulary for server-driven UI: the same ui-* updates feed both a broadcast (everyone) and a ui-response (the acting request). Follows from a design pass with David.
- Rename ui-morph -> ui-update ("morph" is also a mode: value and a poor first guess for "update a target"). ui-morph never shipped (it was the in-flight sse->ui rename this cycle), so no alias for it; sse-morph and the rest keep their aliases. Wire event stays sigil:morph (no client churn, no break for vigil/demo). - ui-response is now THE actor-response builder: a batch of ui-* updates, with an optional status: keyword (default 200) for HTTP hygiene only. The status does not drive the UI. Uses (keys:)+(rest:) so status: composes with the variadic updates. - Deprecate sigil-ui-response (kept as a shim so the demo still builds); new code returns ui-response batches. - Client JS (NON-ADDITIVE): drop the status/!response.ok error-target branch and data-sg-error handling. Event-stream dispatch is the one path; the legacy Sigil-UI-Merge header path stays only for the deprecated sigil-ui-response. Errors are now an app-layer concern: emit a ui-update targeting an error element. Verified no sigil-web app in the workspace uses data-sg-error / status-driven error UI (vigil, demo, blackice, easel, folio, kiln, live-crafter, sigil-site). - live.sgl uses ui-update.
assets/sigil-web/js/sigil-web-ui.js | 31 +++++++++++--------------------
src/sigil/web/live.sgl | 10 +++++-----
src/sigil/web/ui.sgl | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------
3 files changed, 82 insertions(+), 76 deletions(-)assets/sigil-web/js/sigil-web-ui.jsmodified
/** * Perform an action (HTTP request) */ async action(url, { method = 'get', body, target, mode = 'morph', errorTarget } = {}) { async action(url, { method = 'get', body, target, mode = 'morph' } = {}) { const options = { method: method.toUpperCase(), headers: {} return; } // Headers override client-side defaults const mergeTarget = response.headers.get('Sigil-UI-Merge-Target') || target; const mergeMode = response.headers.get('Sigil-UI-Merge-Mode') || mode; // SSE response - parse and execute events // Event-stream response: a batch of ui-* updates (the ui-response // path). Parse and apply each, regardless of HTTP status -- the // server names its own targets, so the status code does not drive the // UI. Surface errors like any other update (target an error element). if (contentType.includes('text/event-stream')) { const text = await response.text(); this.processSSEResponse(text); return; } // Plain HTML response // Legacy single-fragment path (deprecated sigil-ui-response): an HTML // body morphed into the target named by the Sigil-UI-Merge-* headers // (falling back to the triggering element's data-sg-target). const mergeTarget = response.headers.get('Sigil-UI-Merge-Target') || target; const mergeMode = response.headers.get('Sigil-UI-Merge-Mode') || mode; const html = await response.text(); // Error response - use header target, then data-sg-error, then skip if (!response.ok) { const errTarget = response.headers.get('Sigil-UI-Merge-Target') || errorTarget; if (errTarget) { this.handleMorph({ target: errTarget, html, mode: 'inner' }); } return; } // Success - morph to target if (mergeTarget) { this.handleMorph({ target: mergeTarget, html, mode: mergeMode }); } const mode = el.dataset.sgMode || 'morph'; const loadingClass = el.dataset.sgLoading; const confirmMsg = el.dataset.sgConfirm; const errorTarget = el.dataset.sgError; const include = el.dataset.sgInclude; // Confirmation dialog targetIndicators.forEach(ind => ind.classList.add('active')); try { await this.action(url, { method, body, target, mode, errorTarget }); await this.action(url, { method, body, target, mode }); } finally { // Remove loading class if (loadingClass) {src/sigil/web/live.sglmodified
;;; (live-collection render: item-row container: "#todo-list" endpoint: "/events"));;;;;; (define (create-handler req);;; (live-add! todos (dict text: (form-text req) done?: #f)) ; everyone: append row;;; (sigil-ui-response target: "#add-form" content: (add-form))) ; you: reset the form;;; (live-add! todos (dict text: (form-text req) done?: #f)) ; everyone: append row;;; (ui-response (ui-update target: "#add-form" content: (add-form)))) ; you: reset the form;;; ```(define-library (sigil web live) (let* ((it (store-add! (live-collection-rec-store coll) item)) (row ((live-collection-rec-render coll) it))) (broadcast-send (live-collection-rec-hub coll) (ui-morph target: (live-collection-rec-container coll) (ui-update target: (live-collection-rec-container coll) mode: "append" content: row)) it)) (when it (let ((sel+row (render-row+selector coll it))) (broadcast-send (live-collection-rec-hub coll) (ui-morph target: (car sel+row) content: (cdr sel+row))))) (ui-update target: (car sel+row) content: (cdr sel+row))))) it)) ;;; Remove item `id`: delete it from the store and broadcast a remove of ;;; The current-state snapshot: a UI update that replaces the container's ;;; contents with every current row. Sent to each browser on connect. (define (live-snapshot coll) (ui-morph target: (live-collection-rec-container coll) (ui-update target: (live-collection-rec-container coll) mode: "inner" content: (map (live-collection-rec-render coll) (store-all (live-collection-rec-store coll)))))src/sigil/web/ui.sglmodified
;;; The server is the source of truth; the client is a thin rendering layer.;;;;;; Core concepts:;;; - Server-driven UI updates (ui-morph, ui-eval, ui-redirect, ...) that;;; feed both live SSE broadcasts and per-request responses;;; - HTTP headers for single-target responses (Sigil-UI-Merge-*);;; - Server-driven UI updates (ui-update, ui-remove, ui-eval, ui-redirect,;;; ...): one vocabulary for both live broadcasts and per-request responses;;; - `ui-response` delivers a batch of those updates to the acting request;;; - Declarative HTML attributes (data-sg-*) for client interactions;;;;;; Example:;;; (sigil http));;;;;; ;; Broadcast a UI update to every connected client;;; (broadcast-send hub (ui-morph target: "#chat" mode: "append";;; content: `(div (@ (class "msg")) "Hello!")));;; (broadcast-send hub (ui-update target: "#chat" mode: "append";;; content: `(div (@ (class "msg")) "Hello!")));;;;;; ;; Return HTML with merge headers (reaches only the acting request);;; (sigil-ui-response target: "#results" mode: "inner";;; content: (map render-item items));;; ;; Respond to just the acting request with the same vocabulary;;; (ui-response (ui-update target: "#results" mode: "inner";;; content: (map render-item items)));;; ```(define-library (sigil web ui) (sigil resources)) (export ;; Server-driven UI updates (transport-neutral names) ui-morph ;; Server-driven UI updates ui-update ui-remove ui-class ui-eval ui-flash ui-response ;; Deprecated aliases (sse-* names, kept through 1.0) ;; Deprecated aliases (kept through 1.0) sse-morph sse-remove sse-class ;; Historical note: these were named `sse-*`. The `ui-*` names are the ;; idiom now; every `sse-*` name remains as a deprecated alias through 1.0. ;;; Format a UI morph update. ;;; Update a target element with HTML content. ;;; ;;; Morphs HTML content into a target element. `content:` accepts a string, ;;; a single SXML node, or a list of SXML nodes (joined automatically). ;;; The workhorse UI update: it replaces, morphs, or inserts content at a ;;; target element. `content:` accepts a string, a single SXML node, or a ;;; list of SXML nodes (joined automatically). `mode:` selects how the ;;; content lands; the default `"morph"` diffs the target in place via ;;; idiomorph (preserving focus/scroll). ;;; ;;; Parameters: ;;; target: CSS selector for the target element (e.g., "#chat") ;;; content: string, SXML node, or list of SXML nodes to morph ;;; mode: Morph mode (default: "morph") ;;; settle: Milliseconds to wait after morph (for CSS transitions) ;;; content: string, SXML node, or list of SXML nodes ;;; mode: how the content lands (default: "morph") ;;; settle: Milliseconds to wait after applying (for CSS transitions) ;;; ;;; Modes: ;;; "morph" - Intelligent diff/patch via idiomorph (default) ;;; ;;; Example: ;;; ```scheme ;;; (ui-morph target: "#messages" mode: "append" ;;; content: `(div (@ (class "msg")) "Hello!")) ;;; (ui-update target: "#messages" mode: "append" ;;; content: `(div (@ (class "msg")) "Hello!")) ;;; ``` (define (ui-morph (keys: (target #f) (content #f) (mode "morph") (settle #f))) (define (ui-update (keys: (target #f) (content #f) (mode "morph") (settle #f))) (let ((html (content->html content))) (string-append "event: sigil:morph\n" ;;; Format a UI remove update. ;;; ;;; Removes the target element from the DOM. Shorthand for ;;; `(ui-morph target: TARGET mode: "remove")`. ;;; `(ui-update target: TARGET mode: "remove")`. ;;; ;;; Example: ;;; ```scheme ;;; (ui-remove target: "#notification") ;;; ``` (define (ui-remove (keys: (target #f))) (ui-morph target: target mode: "remove")) (ui-update target: target mode: "remove")) ;;; Format a UI class update. ;;; (set! headers (cons (cons 'Sigil-UI-Merge-Mode mode) headers))) (list->dict headers))) ;;; Create an HTML response with Sigil-UI merge headers. ;;; DEPRECATED. Prefer `ui-response` with a `ui-update`: ;;; (ui-response (ui-update target: "#chat" mode: "append" content: ...)) ;;; ;;; A fragment-over-plain-HTTP response: reaches only the acting request ;;; (not a broadcast). `content:` accepts a string, a single SXML node, ;;; or a list of SXML nodes (joined automatically). ;;; Create a single-fragment HTML response carried via `Sigil-UI-Merge-*` ;;; headers. This is the pre-unification actor-response mechanism, kept as a ;;; shim so existing consumers keep working; new code should return ;;; `ui-response` batches (the same `ui-*` vocabulary you broadcast). ;;; ;;; Example: ;;; ```scheme Sigil-UI-Redirect: url) body: "")) ;;; Bundle UI updates into a single response for the acting request. ;;; Respond to the acting request with a batch of UI updates. ;;; ;;; Pairs with `sigil-ui-response` (which carries one HTML fragment via ;;; headers); `ui-response` carries any number of `ui-*` updates in the ;;; body, applied by the client on arrival. Call with no arguments to ;;; return an empty response when the visible effect goes out over a ;;; broadcast instead and the actor needs no direct change. ;;; This is THE response an action handler returns: any number of `ui-*` ;;; updates, applied by the client on arrival. It's the same vocabulary you ;;; broadcast to every client, so a handler reads as "the mutation is what ;;; everyone sees; this response is what the acting user sees." ;;; ;;; Call with no updates to return an empty response — the common case when ;;; the visible change already went out over a broadcast and the actor needs ;;; nothing extra. ;;; ;;; The optional `status:` keyword sets the HTTP status (default 200), ;;; purely for transport hygiene (logs, proxies, API clients). The status ;;; does not drive the UI: the client applies the updates regardless. ;;; Surface an error to the user like any other update, by targeting an ;;; error element. ;;; ;;; Example: ;;; ```scheme ;;; (ui-response ;;; (ui-morph target: "#sidebar" content: new-sidebar) ;;; (ui-morph target: "#main" content: new-main) ;;; (ui-eval cmd: "focus" target: "#input")) ;;; ;;; (ui-response) ; nothing to do on the actor's side ;;; (ui-response) ; broadcast handled it ;;; (ui-response (ui-update target: "#add-form" content: (add-form))) ;;; (ui-response status: 422 ;;; (ui-update target: "#errors" content: (errors msgs))) ;;; ``` (define (ui-response . updates) (define (ui-response (keys: (status 200)) (rest: updates)) (http-response status: 200 status: status headers: (dict content-type: "text/event-stream" cache-control: "no-cache") ;; Deprecated aliases — `sse-*` UI-update names ;; ============================================================ ;; ;; The UI-update family was renamed `sse-*` -> `ui-*` (the updates are ;; transport-neutral, not SSE-specific). These aliases keep existing code ;; working through 1.0; prefer the `ui-*` names in new code. (define sse-morph ui-morph) ;; The UI-update family shipped as `sse-*` (e.g. `sse-morph`); the updates ;; are transport-neutral, so the current names are `ui-*` (`ui-update`, ;; `ui-remove`, ...). These aliases keep already-released consumers (vigil, ;; the demo) working through 1.0; prefer the `ui-*` names in new code. ;; (No `ui-morph` alias: that name never shipped — it was renamed to ;; `ui-update` before release.) (define sse-morph ui-update) (define sse-remove ui-remove) (define sse-class ui-class) (define sse-eval ui-eval) (target "#sg-flash-container") (class #f) (id #f))) (ui-morph target: target mode: "append" content: (flash-message type: type (ui-update target: target mode: "append" content: (flash-message type: type message: message remove-after: remove-after class: class