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