Commitb591b507Recorded5 Jul 2026Repositorysigil-web

Unify actor responses on ui-response; rename ui-morph -> ui-update

Message

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.

Changed
 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(-)
Diff
assets/sigil-web/js/sigil-web-ui.jsmodified
@@ -361,7 +361,7 @@
361
/**
362
* Perform an action (HTTP request)
363
*/
364
async action(url, { method = 'get', body, target, mode = 'morph', errorTarget } = {}) {
+364
async action(url, { method = 'get', body, target, mode = 'morph' } = {}) {
365
const options = {
366
method: method.toUpperCase(),
367
headers: {}
@@ -402,30 +402,22 @@
402
return;
403
}
404
405
// Headers override client-side defaults
406
const mergeTarget = response.headers.get('Sigil-UI-Merge-Target') || target;
407
const mergeMode = response.headers.get('Sigil-UI-Merge-Mode') || mode;
408
409
// SSE response - parse and execute events
+405
// Event-stream response: a batch of ui-* updates (the ui-response
+406
// path). Parse and apply each, regardless of HTTP status -- the
+407
// server names its own targets, so the status code does not drive the
+408
// UI. Surface errors like any other update (target an error element).
409
if (contentType.includes('text/event-stream')) {
410
const text = await response.text();
411
this.processSSEResponse(text);
412
return;
413
}
414
416
// Plain HTML response
+415
// Legacy single-fragment path (deprecated sigil-ui-response): an HTML
+416
// body morphed into the target named by the Sigil-UI-Merge-* headers
+417
// (falling back to the triggering element's data-sg-target).
+418
const mergeTarget = response.headers.get('Sigil-UI-Merge-Target') || target;
+419
const mergeMode = response.headers.get('Sigil-UI-Merge-Mode') || mode;
420
const html = await response.text();
418
419
// Error response - use header target, then data-sg-error, then skip
420
if (!response.ok) {
421
const errTarget = response.headers.get('Sigil-UI-Merge-Target') || errorTarget;
422
if (errTarget) {
423
this.handleMorph({ target: errTarget, html, mode: 'inner' });
424
}
425
return;
426
}
427
428
// Success - morph to target
421
if (mergeTarget) {
422
this.handleMorph({ target: mergeTarget, html, mode: mergeMode });
423
}
@@ -719,7 +711,6 @@
711
const mode = el.dataset.sgMode || 'morph';
712
const loadingClass = el.dataset.sgLoading;
713
const confirmMsg = el.dataset.sgConfirm;
722
const errorTarget = el.dataset.sgError;
714
const include = el.dataset.sgInclude;
715
716
// Confirmation dialog
@@ -763,7 +754,7 @@
754
targetIndicators.forEach(ind => ind.classList.add('active'));
755
756
try {
766
await this.action(url, { method, body, target, mode, errorTarget });
+757
await this.action(url, { method, body, target, mode });
758
} finally {
759
// Remove loading class
760
if (loadingClass) {
src/sigil/web/live.sglmodified
@@ -22,8 +22,8 @@
22
;;; (live-collection render: item-row container: "#todo-list" endpoint: "/events"))
23
;;;
24
;;; (define (create-handler req)
25
;;; (live-add! todos (dict text: (form-text req) done?: #f)) ; everyone: append row
26
;;; (sigil-ui-response target: "#add-form" content: (add-form))) ; you: reset the form
+25
;;; (live-add! todos (dict text: (form-text req) done?: #f)) ; everyone: append row
+26
;;; (ui-response (ui-update target: "#add-form" content: (add-form)))) ; you: reset the form
27
;;; ```
28
29
(define-library (sigil web live)
@@ -165,7 +165,7 @@
165
(let* ((it (store-add! (live-collection-rec-store coll) item))
166
(row ((live-collection-rec-render coll) it)))
167
(broadcast-send (live-collection-rec-hub coll)
168
(ui-morph target: (live-collection-rec-container coll)
+168
(ui-update target: (live-collection-rec-container coll)
169
mode: "append"
170
content: row))
171
it))
@@ -178,7 +178,7 @@
178
(when it
179
(let ((sel+row (render-row+selector coll it)))
180
(broadcast-send (live-collection-rec-hub coll)
181
(ui-morph target: (car sel+row) content: (cdr sel+row)))))
+181
(ui-update target: (car sel+row) content: (cdr sel+row)))))
182
it))
183
184
;;; Remove item `id`: delete it from the store and broadcast a remove of
@@ -203,7 +203,7 @@
203
;;; The current-state snapshot: a UI update that replaces the container's
204
;;; contents with every current row. Sent to each browser on connect.
205
(define (live-snapshot coll)
206
(ui-morph target: (live-collection-rec-container coll)
+206
(ui-update target: (live-collection-rec-container coll)
207
mode: "inner"
208
content: (map (live-collection-rec-render coll)
209
(store-all (live-collection-rec-store coll)))))
src/sigil/web/ui.sglmodified
@@ -4,9 +4,9 @@
4
;;; The server is the source of truth; the client is a thin rendering layer.
5
;;;
6
;;; Core concepts:
7
;;; - Server-driven UI updates (ui-morph, ui-eval, ui-redirect, ...) that
8
;;; feed both live SSE broadcasts and per-request responses
9
;;; - HTTP headers for single-target responses (Sigil-UI-Merge-*)
+7
;;; - Server-driven UI updates (ui-update, ui-remove, ui-eval, ui-redirect,
+8
;;; ...): one vocabulary for both live broadcasts and per-request responses
+9
;;; - `ui-response` delivers a batch of those updates to the acting request
10
;;; - Declarative HTML attributes (data-sg-*) for client interactions
11
;;;
12
;;; Example:
@@ -15,12 +15,12 @@
15
;;; (sigil http))
16
;;;
17
;;; ;; Broadcast a UI update to every connected client
18
;;; (broadcast-send hub (ui-morph target: "#chat" mode: "append"
19
;;; content: `(div (@ (class "msg")) "Hello!")))
+18
;;; (broadcast-send hub (ui-update target: "#chat" mode: "append"
+19
;;; content: `(div (@ (class "msg")) "Hello!")))
20
;;;
21
;;; ;; Return HTML with merge headers (reaches only the acting request)
22
;;; (sigil-ui-response target: "#results" mode: "inner"
23
;;; content: (map render-item items))
+21
;;; ;; Respond to just the acting request with the same vocabulary
+22
;;; (ui-response (ui-update target: "#results" mode: "inner"
+23
;;; content: (map render-item items)))
24
;;; ```
25
26
(define-library (sigil web ui)
@@ -33,8 +33,8 @@
33
(sigil resources))
34
35
(export
36
;; Server-driven UI updates (transport-neutral names)
37
ui-morph
+36
;; Server-driven UI updates
+37
ui-update
38
ui-remove
39
ui-class
40
ui-eval
@@ -45,7 +45,7 @@
45
ui-flash
46
ui-response
47
48
;; Deprecated aliases (sse-* names, kept through 1.0)
+48
;; Deprecated aliases (kept through 1.0)
49
sse-morph
50
sse-remove
51
sse-class
@@ -165,16 +165,19 @@
165
;; Historical note: these were named `sse-*`. The `ui-*` names are the
166
;; idiom now; every `sse-*` name remains as a deprecated alias through 1.0.
167
168
;;; Format a UI morph update.
+168
;;; Update a target element with HTML content.
169
;;;
170
;;; Morphs HTML content into a target element. `content:` accepts a string,
171
;;; a single SXML node, or a list of SXML nodes (joined automatically).
+170
;;; The workhorse UI update: it replaces, morphs, or inserts content at a
+171
;;; target element. `content:` accepts a string, a single SXML node, or a
+172
;;; list of SXML nodes (joined automatically). `mode:` selects how the
+173
;;; content lands; the default `"morph"` diffs the target in place via
+174
;;; idiomorph (preserving focus/scroll).
175
;;;
176
;;; Parameters:
177
;;; target: CSS selector for the target element (e.g., "#chat")
175
;;; content: string, SXML node, or list of SXML nodes to morph
176
;;; mode: Morph mode (default: "morph")
177
;;; settle: Milliseconds to wait after morph (for CSS transitions)
+178
;;; content: string, SXML node, or list of SXML nodes
+179
;;; mode: how the content lands (default: "morph")
+180
;;; settle: Milliseconds to wait after applying (for CSS transitions)
181
;;;
182
;;; Modes:
183
;;; "morph" - Intelligent diff/patch via idiomorph (default)
@@ -188,13 +191,13 @@
191
;;;
192
;;; Example:
193
;;; ```scheme
191
;;; (ui-morph target: "#messages" mode: "append"
192
;;; content: `(div (@ (class "msg")) "Hello!"))
+194
;;; (ui-update target: "#messages" mode: "append"
+195
;;; content: `(div (@ (class "msg")) "Hello!"))
196
;;; ```
194
(define (ui-morph (keys: (target #f)
195
(content #f)
196
(mode "morph")
197
(settle #f)))
+197
(define (ui-update (keys: (target #f)
+198
(content #f)
+199
(mode "morph")
+200
(settle #f)))
201
(let ((html (content->html content)))
202
(string-append
203
"event: sigil:morph\n"
@@ -211,14 +214,14 @@
214
;;; Format a UI remove update.
215
;;;
216
;;; Removes the target element from the DOM. Shorthand for
214
;;; `(ui-morph target: TARGET mode: "remove")`.
+217
;;; `(ui-update target: TARGET mode: "remove")`.
218
;;;
219
;;; Example:
220
;;; ```scheme
221
;;; (ui-remove target: "#notification")
222
;;; ```
223
(define (ui-remove (keys: (target #f)))
221
(ui-morph target: target mode: "remove"))
+224
(ui-update target: target mode: "remove"))
225
226
;;; Format a UI class update.
227
;;;
@@ -374,11 +377,13 @@
377
(set! headers (cons (cons 'Sigil-UI-Merge-Mode mode) headers)))
378
(list->dict headers)))
379
377
;;; Create an HTML response with Sigil-UI merge headers.
+380
;;; DEPRECATED. Prefer `ui-response` with a `ui-update`:
+381
;;; (ui-response (ui-update target: "#chat" mode: "append" content: ...))
382
;;;
379
;;; A fragment-over-plain-HTTP response: reaches only the acting request
380
;;; (not a broadcast). `content:` accepts a string, a single SXML node,
381
;;; or a list of SXML nodes (joined automatically).
+383
;;; Create a single-fragment HTML response carried via `Sigil-UI-Merge-*`
+384
;;; headers. This is the pre-unification actor-response mechanism, kept as a
+385
;;; shim so existing consumers keep working; new code should return
+386
;;; `ui-response` batches (the same `ui-*` vocabulary you broadcast).
387
;;;
388
;;; Example:
389
;;; ```scheme
@@ -415,26 +420,33 @@
420
Sigil-UI-Redirect: url)
421
body: ""))
422
418
;;; Bundle UI updates into a single response for the acting request.
+423
;;; Respond to the acting request with a batch of UI updates.
424
;;;
420
;;; Pairs with `sigil-ui-response` (which carries one HTML fragment via
421
;;; headers); `ui-response` carries any number of `ui-*` updates in the
422
;;; body, applied by the client on arrival. Call with no arguments to
423
;;; return an empty response when the visible effect goes out over a
424
;;; broadcast instead and the actor needs no direct change.
+425
;;; This is THE response an action handler returns: any number of `ui-*`
+426
;;; updates, applied by the client on arrival. It's the same vocabulary you
+427
;;; broadcast to every client, so a handler reads as "the mutation is what
+428
;;; everyone sees; this response is what the acting user sees."
+429
;;;
+430
;;; Call with no updates to return an empty response — the common case when
+431
;;; the visible change already went out over a broadcast and the actor needs
+432
;;; nothing extra.
+433
;;;
+434
;;; The optional `status:` keyword sets the HTTP status (default 200),
+435
;;; purely for transport hygiene (logs, proxies, API clients). The status
+436
;;; does not drive the UI: the client applies the updates regardless.
+437
;;; Surface an error to the user like any other update, by targeting an
+438
;;; error element.
439
;;;
440
;;; Example:
441
;;; ```scheme
428
;;; (ui-response
429
;;; (ui-morph target: "#sidebar" content: new-sidebar)
430
;;; (ui-morph target: "#main" content: new-main)
431
;;; (ui-eval cmd: "focus" target: "#input"))
432
;;;
433
;;; (ui-response) ; nothing to do on the actor's side
+442
;;; (ui-response) ; broadcast handled it
+443
;;; (ui-response (ui-update target: "#add-form" content: (add-form)))
+444
;;; (ui-response status: 422
+445
;;; (ui-update target: "#errors" content: (errors msgs)))
446
;;; ```
435
(define (ui-response . updates)
+447
(define (ui-response (keys: (status 200)) (rest: updates))
448
(http-response
437
status: 200
+449
status: status
450
headers: (dict
451
content-type: "text/event-stream"
452
cache-control: "no-cache")
@@ -507,11 +519,14 @@
519
;; Deprecated aliases — `sse-*` UI-update names
520
;; ============================================================
521
;;
510
;; The UI-update family was renamed `sse-*` -> `ui-*` (the updates are
511
;; transport-neutral, not SSE-specific). These aliases keep existing code
512
;; working through 1.0; prefer the `ui-*` names in new code.
513
514
(define sse-morph ui-morph)
+522
;; The UI-update family shipped as `sse-*` (e.g. `sse-morph`); the updates
+523
;; are transport-neutral, so the current names are `ui-*` (`ui-update`,
+524
;; `ui-remove`, ...). These aliases keep already-released consumers (vigil,
+525
;; the demo) working through 1.0; prefer the `ui-*` names in new code.
+526
;; (No `ui-morph` alias: that name never shipped — it was renamed to
+527
;; `ui-update` before release.)
+528
+529
(define sse-morph ui-update)
530
(define sse-remove ui-remove)
531
(define sse-class ui-class)
532
(define sse-eval ui-eval)
@@ -950,9 +965,9 @@
965
(target "#sg-flash-container")
966
(class #f)
967
(id #f)))
953
(ui-morph target: target
954
mode: "append"
955
content: (flash-message type: type
+968
(ui-update target: target
+969
mode: "append"
+970
content: (flash-message type: type
971
message: message
972
remove-after: remove-after
973
class: class