Refresh sigil-web docs to the current API; add live.md
The docs/ were from 2026-03-31 and documented the deprecated names (sse-*, sigil-ui-response, sse-response-batch) and the pre-default-POST, positional-route API. Rewrite to the current surface, verified against the exports on this branch.
- ui.md: http-response/page + /sxml, with-sigil-ui, ui-update (+ the mode table incl. morph/morph-inner), ui-remove/class/eval/redirect/reload/flash, ui-response (sole actor-response builder, optional status:), current sg-* (symbol methods, POST default, positional labels), widgets, and a short deprecated-aliases note. - routing.md: keyword route (method symbols, POST default), router/routes, ui-routes, path-param, the middleware table incl. with-sigil-ui. Fixed the positional (route GET "/" h) examples that never actually worked. - live.md (new): the whole (sigil web live) module — live-collection, live-stream, live-add!/update!/remove!, live-get/all, live-push!, live-list-view, live-subscribe, live-routes, on-change:, with a collection-free feed pattern.
Style matches sigil-http/docs/http.md (terse, ### per function + short example, ## Common Patterns). Docs build clean (advisory >200-line warnings only, as with http.md).
docs/live.md | 189 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
docs/routing.md | 63 ++++++++++++++++++++++---------
docs/ui.md | 362 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------------------------------------------------------
3 files changed, 379 insertions(+), 235 deletions(-)docs/live.mdadded
# Live Collections & Streams> Push server-driven UI updates to every connected browser over SSE, with no> hand-written hub, event route, snapshot, or heartbeat.```scheme(import (sigil web live))```Two building blocks:- A **`live-stream`** is a push channel clients subscribe to. You `live-push!` `ui-*` updates and every subscriber applies them. No store, no render — the general case (a chat feed, a room log, notifications).- A **`live-collection`** is a `live-stream` plus an id-keyed store and a per-item render function. Mutating it broadcasts the right DOM patch automatically: a new row appends, an updated row morphs in place by id, a removed row is deleted by id.A mutation/push is what **everyone** sees; a handler's return value is what the**acting user** sees (usually an empty `ui-response`, or nothing under`ui-routes`).The client is served by `with-sigil-ui` and injected by `http-response/page`;each stream's endpoint is registered with `live-routes`.## Collections### live-collectionCreate a collection: a stream whose subscribers see a list of rendered rows keptin sync as you mutate it. The `render` function's top-level element MUST carry an`id` — the collection targets that id when it morphs or removes the row.```scheme(define todos (live-collection render: item-row container: "#todo-list" path: "/events"))```- `render:` — `item -> SXML` for one row (its top element has an `id`).- `container:` — CSS id-selector of the list element rows live in.- `path:` — the SSE path the page subscribes to (default `"/events"`).- `on-change:` — optional, see below.### live-add! / live-update! / live-remove!Mutate the store and broadcast the matching patch to every client. They returnthe affected item (or `#f`), **not** an HTTP response.```scheme(live-add! todos (dict text: "Buy milk" done?: #f)) ; broadcast: append row(live-update! todos id (lambda (i) (dict-set i done?: #t))) ; broadcast: morph row by id(live-remove! todos id) ; broadcast: remove row by id```The collection assigns the `id:` field on `live-add!`. `live-update!` takes afunction `item -> item` (dicts are immutable; return a new one with `dict-set`).### live-get / live-allRead accessors (for a handler that needs current state, e.g. an inline editform). Read-only.```scheme(live-get todos id) ; => item or #f(live-all todos) ; => items in insertion order```### live-list-viewRender the collection's list: the `<ul>` (or container) with the current rows,plus the hidden element that subscribes the page to the stream. Drop it in a body.```scheme(http-response/page "To-Do" `(div (h1 "To-Do") ,(add-form) ,(live-list-view todos)))```### on-change:An optional `(items -> ui-update or list)` on `live-collection`, broadcast after**every** mutation. For UI derived from the whole list — a count, a summary. Itreceives only the item list; per-action events go through `live-push!`.```scheme(define todos (live-collection render: item-row container: "#todo-list" path: "/events" on-change: (lambda (items) (ui-update target: "#count" mode: "inner" content: (count-label (length items))))))```## Streams### live-streamA bare push channel — hub + SSE route + lazy heartbeat + subscription element,with no store or render. `on-connect:` is an optional thunk returning an initial`ui-update` string sent to each new subscriber (a snapshot); a plain feed omitsit, so late joiners see only future pushes.```scheme(define log (live-stream path: "/log"))```### live-push!Send a `ui-update` (or a list of them) to every subscriber of a stream **or** acollection.```scheme(live-push! log (ui-update target: "#log" mode: "append" content: `(li "Item added")))```### live-subscribeThe hidden `data-sg-sse` element that subscribes a page to a stream (or acollection). `live-list-view` includes one for its collection; use`live-subscribe` directly for a bare stream.```scheme`(div (h2 "Activity") (ul (@ (id "log"))) ,(live-subscribe log))```## Routing### live-routesReturn a handler that serves the SSE endpoint for a stream or a collection.Combine it with your app's routes.```scheme(define app (-> (ui-routes (router …item routes…) (live-routes todos) ; registers /events (live-routes log)) ; registers /log (with-sigil-ui) (with-logging) (with-not-found)))```The heartbeat starts lazily on the first connection (inside the server's asynccontext), so `main` is just `(with-async (http-serve app port: 8080))` — noexplicit start call.## Common Patterns### CRUD handlers over a live collectionUnder `ui-routes`, each handler is the mutation; the acting user gets an emptyacknowledgement while everyone gets the broadcast.```scheme(define (create-handler req) (live-add! todos (dict text: (form-text req) done?: #f)) (ui-response (ui-update target: "#add-form" content: (add-form)))) ; reset the form(define (toggle-handler req) (live-update! todos (item-id req) (lambda (i) (dict-set i done?: (not (dict-ref i done?:))))))(define (delete-handler req) (live-remove! todos (item-id req)))```### A collection-free feed (chat / room log / notifications)The general case: a bare `live-stream` and `live-push!`. No store, no render —just push a `ui-update` on each event.```scheme(define chat (live-stream path: "/chat"));; page body: (ul (@ (id "messages"))) + (live-subscribe chat)(define (say-handler req) (live-push! chat (ui-update target: "#messages" mode: "append" content: `(li ,(form-text req)))) (ui-response))```### Derived summary UIUse `on-change:` for anything computed from the whole list (a count, totals, an"all done" flag); it re-broadcasts after every mutation, so the summary stays insync without touching the handlers.docs/routing.mdmodified
## RoutesDefine routes with an HTTP method, URL pattern, and handler function. Handlers receive a request and return a response (or `#f` for no match).Define routes with an HTTP method, URL pattern, and handler. Handlers receive arequest and return a response (or `#f` for no match). `method:` takes anHTTP-method **symbol** and defaults to `POST` when omitted (a `"post"` string isaccepted too).```scheme(define app (router (route GET "/" home-handler) (route GET "/about" about-handler) (route POST "/api/login" login-handler))) (route method: GET pattern: "/" handler: home-handler) (route method: GET pattern: "/about" handler: about-handler) (route method: POST pattern: "/api/login" handler: login-handler)))```Available method constants: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `OPTIONS`, `HEAD`, `ANY`.`ANY` matches all HTTP methods.Available method symbols: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `OPTIONS`,`HEAD`, `ANY`. `ANY` matches all HTTP methods.## Pattern Matching```scheme(router (route GET "/users/:id" user-handler) (route GET "/files/*path" file-handler)) (route method: GET pattern: "/users/:id" handler: user-handler) (route method: GET pattern: "/files/*path" handler: file-handler))```### Path Parameters## Route Combination`routes` combines multiple handlers into one. The first handler to return a non-`#f` response wins.`routes` combines multiple handlers into one. The first handler to return anon-`#f` response wins.```scheme(define api-routes (router (route GET "/api/users" list-users) (route POST "/api/users" create-user))) (route method: GET pattern: "/api/users" handler: list-users) (route method: POST pattern: "/api/users" handler: create-user)))(define page-routes (router (route GET "/" home-page) (route GET "/about" about-page))) (route method: GET pattern: "/" handler: home-page) (route method: GET pattern: "/about" handler: about-page)))(define app (routes api-routes page-routes))```### ui-routes`ui-routes` is a `routes` analog for hypermedia handlers: it combines handlersthe same way, but a handler that returns a value which **isn't** an HTTP responseis taken as "handled, nothing extra for the acting user" and becomes an empty`(ui-response)`. So an action handler can end with just its mutation, no trailing`(ui-response)`:```scheme(define (toggle-handler req) (live-update! todos (item-id req) (lambda (i) (dict-set i done?: (not (dict-ref i done?:)))))) ; no ui-response needed(define app (-> (routes (ui-routes (router …hypermedia…)) ; non-response returns -> empty ack (router …json-api…)) ; plain routes never coerce (with-sigil-ui) (with-logging) (with-not-found)))````#f` still falls through (a missing-id mutation → 404 via `with-not-found`), andexplicit responses pass through untouched. Keep JSON APIs and webhooks underplain `routes`/`router`, which never coerce. `ui-routes` is exported from`(sigil web ui)`.## MiddlewareMiddleware wraps a handler to add cross-cutting concerns. Two styles are supported.| `with-cors` | `allow-origin:`, `allow-methods:`, `allow-headers:` | Add CORS headers (default: allow all origins) || `with-content-type` | `default:` | Set default Content-Type (default: `"text/html"`) || `with-not-found` | `body:` | Return 404 if handler returns `#f` || `with-sigil-ui` | `path:` | Serve the client script at `/js/sigil-web-ui.js` (from `(sigil web ui)`) |```scheme;; Custom CORS settings;; Use in a router with a wildcard pattern(define app (-> (routes (router (route GET "/" home-handler)) (router (route method: GET pattern: "/" handler: home-handler)) (make-static-handler "public/" '((prefix . "/static")))) (with-not-found)))(define app (-> (routes (router (route GET "/" home-handler) (route GET "/users/:id" user-handler)) (route method: GET pattern: "/" handler: home-handler) (route method: GET pattern: "/users/:id" handler: user-handler)) (make-static-handler "public/")) (with-logging) (with-cors)docs/ui.mdmodified
# UI# Web UI> Server-driven UI components and real-time SSE updates.> Server-driven hypermedia: render SXML views, push `ui-*` updates to the> browser, and wire interactions with declarative `data-sg-*` attributes. The> server is the source of truth; the bundled client applies the DOM changes.```scheme(import (sigil web ui) (sigil web) (sigil http))(import (sigil web ui))```The `(sigil web ui)` module provides server-side rendering of interactive components. The server is the source of truth; the client is a thin rendering layer powered by a small JavaScript library.The same `ui-*` update vocabulary feeds both a broadcast (every client) and aper-request `ui-response` (the acting client). The wire events are `sigil:*`;the client is served by `with-sigil-ui` (or `sigil-web-ui-handler`).## Setup## Page ResponsesInclude the client library in your HTML head and add a route to serve it:### http-response/pageRespond with a complete HTML page from a title and a body. The default templatesupplies `<head>` (charset, viewport, `<title>`) and auto-injects the clientscript, so a page is script-wired with zero head plumbing.```scheme(define (layout title body) (sxml->html `(html (head ,@(sigil-web-ui-head) (title ,title)) (body ,@body))))(http-response/page "To-Do" `(div (@ (class "app")) (h1 "To-Do") ,(todo-list)))(define app (router (route GET "/js/sigil-web-ui.js" (sigil-web-ui-handler)) ...));; Custom status, or a custom shell (a (title body) -> full-page-sxml function):(http-response/page "Not found" '(p "No such item.") status: 404)(http-response/page "Docs" body template: my-template)````sigil-web-ui-head` returns script tags for the idiomorph library and the Sigil UI script. Customize URLs with `idiomorph-url:` and `script-url:` keywords.### http-response/sxml## SSE UpdatesThe escape hatch: serialize a full SXML page (you bring the whole `<html>`) to anHTML document. Prepends `<!DOCTYPE html>`, sets `text/html`.Server-Sent Events allow pushing real-time updates from the server. Each helper produces an SSE-formatted string to send via chunked response.```scheme(http-response/sxml `(html (head (title "Hi")) (body (h1 "Hi"))))```### sse-morph### with-sigil-uiMorph HTML content into a target element.Middleware that serves the client script at `/js/sigil-web-ui.js`, so you neverregister that route by hand. Pairs with `http-response/page` injecting the tag.```scheme(sse-morph target: "#messages" mode: "append" content: `(div (@ (class "msg")) "New message!"))(-> (routes (router …)) (with-sigil-ui) (with-logging) (with-not-found))```| Mode | Description ||------|-------------|| `"morph"` | Intelligent diff/patch via idiomorph (default) || `"replace"` | Replace target's outerHTML || `"inner"` | Replace target's innerHTML || `"append"` | Append to target's children || `"prepend"` | Prepend to target's children || `"before"` | Insert before target || `"after"` | Insert after target |## UI UpdatesThe `settle:` keyword adds a delay in milliseconds after morphing, useful for CSS transitions.`ui-update` and friends build server-driven UI updates. Feed them to a broadcast(see `(sigil web live)`) or bundle them in a `ui-response` for the acting request.### sse-remove### ui-updateRemove an element from the DOM.Update a target element with HTML content. `content:` accepts a string, a singleSXML node, or a list of SXML nodes (joined automatically).```scheme(sse-remove target: "#notification")(ui-update target: "#messages" mode: "append" content: `(div (@ (class "msg")) "Hello!"))```### sse-classAdd or remove CSS classes on a target element.`mode:` selects how the content lands (default `"morph"`):```scheme(sse-class target: "#panel" add: "visible active")(sse-class target: "#btn" remove: "loading" add: "done")```| mode | effect ||---------------|---------------------------------------------------------------|| `morph` | Morph the target **element itself** via idiomorph, in place (default). Content is a full element whose id matches the target — the common case. || `morph-inner` | Morph the target's **inner** content via idiomorph. || `inner` | Replace the target's innerHTML (no diffing). || `replace` | Replace the target's outerHTML (no diffing). || `append` | Append to the target's children. || `prepend` | Prepend to the target's children. || `before` / `after` | Insert before / after the target. || `remove` | Remove the target element. |### sse-eval### ui-removeExecute a limited client command (focus, scroll).Remove the target element. Shorthand for `(ui-update target: … mode: "remove")`.```scheme(sse-eval cmd: "focus" target: "#input")(sse-eval cmd: "scroll-to" target: "#chat" position: "bottom")(ui-remove target: "#item-3")```### sse-redirect### ui-classRedirect the browser to a new URL.Add or remove CSS classes on a target.```scheme(sse-redirect url: "/login")(ui-class target: "#panel" add: "visible active")(ui-class target: "#btn" remove: "loading" add: "done")```## Response Helpers### sigil-ui-response### ui-evalCreate an HTML response with merge headers for single-target updates.Run a limited client command (things HTML state can't express).```scheme(sigil-ui-response target: "#results" mode: "inner" content: `(ul ,@(map render-item items)))(ui-eval cmd: "focus" target: "#input")(ui-eval cmd: "scroll-to" target: "#chat" position: "bottom")```### sigil-ui-redirectTrigger a client-side redirect (falls back to HTTP 302 for non-JS clients).### ui-redirect / ui-reload```scheme(sigil-ui-redirect url: "/dashboard")(ui-redirect url: "/login")(ui-reload) ; re-fetch the page and morph <body>(ui-reload target: "#sidebar") ; morph one element from a re-fetch```### sse-response-batch### ui-flashSend multiple SSE events in one response for multi-target updates.Push a flash message (append into a container, default `#sg-flash-container`).```scheme(sse-response-batch (sse-morph target: "#sidebar" content: new-sidebar) (sse-morph target: "#main" content: new-content) (sse-eval cmd: "focus" target: "#search"))(ui-flash type: 'success message: "Saved!" remove-after: 3000)```## Form FieldsAlso available: `ui-css-reload`, `ui-js`, and `flash-message` (the flash SXMLwithout pushing it).Form field helpers generate SXML with labels, error display, and standard HTML attributes.| Helper | HTML type ||--------|-----------|| `sg-text-field` | `<input type="text">` || `sg-email-field` | `<input type="email">` || `sg-password-field` | `<input type="password">` || `sg-hidden-field` | `<input type="hidden">` || `sg-textarea-field` | `<textarea>` || `sg-select-field` | `<select>` || `sg-checkbox-field` | `<input type="checkbox">` || `sg-submit-button` | `<button type="submit">` |Common keywords shared by most fields:| Keyword | Description ||---------|-------------|| `name:` | Input name attribute || `value:` | Current value || `label:` | Label text (wraps in `<label>`) || `placeholder:` | Placeholder text || `required:` | Add required attribute || `disabled:` | Add disabled attribute || `error:` | Error message (shown in `<span>`) || `class:` | CSS class || `id:` | Element ID |## Responding to an Action```scheme(sg-text-field name: "username" label: "Username" placeholder: "Enter name" required: #t)### ui-response(sg-select-field name: "role" label: "Role" options: '(("admin" . "Admin") ("user" . "User")) value: "user")The response an action handler returns: a batch of `ui-*` updates the clientapplies. Same vocabulary you broadcast, so a handler reads as "the mutation iswhat everyone sees; this response is what the acting user sees." Call it with noupdates for an empty acknowledgement (the common case when a broadcast alreadydid the visible work). The optional `status:` is for HTTP hygiene only — it doesnot drive the UI; surface an error by targeting an error element.(sg-submit-button label: "Save" loading: "opacity-50")```scheme(ui-response) ; empty ack(ui-response (ui-update target: "#add-form" content: (add-form)))(ui-response status: 422 (ui-update target: "#errors" content: (errors msgs)))```## Interactive Components`ui-routes` (see the routing docs) lets a handler skip the empty `ui-response`and just end with its mutation.### sg-button## ComponentsCreate a button that triggers a server action via `data-sg-*` attributes.`sg-*` helpers emit HTML with `data-sg-*` attributes; the client fetches theroute and applies the result — you never write the fetch. Action helpers acceptan HTTP-method **symbol** and default to `POST`.### sg-button```scheme(sg-button "Like" action: "/api/like" method: "post" target: "#count" loading: "opacity-50")(sg-button "Done" action: "/items/1/toggle") ; POST by default(sg-button "Load" action: "/data" method: GET)```### sg-linkCreate a link that morphs content into a target (SPA-style navigation).A link that morphs a fetched fragment into `target:` (falls back to normalnavigation for non-JS clients). Children are a string or a list of nodes.```scheme(sg-link "Introduction" action: "/lesson/1" target: "#content")(sg-link "Edit" action: "/items/1/edit" target: "#item-1")```### sg-formCreate a form with action handling and optional error targeting.```scheme(sg-form (list (sg-email-field name: "email" label: "Email") (sg-password-field name: "password" label: "Password") (sg-submit-button label: "Login")) action: "/api/login" method: "post" target: "#result" error-target: "#errors")```### sg-sseCreate an SSE-connected container that receives real-time updates.```scheme(sg-sse '((div (@ (id "messages")))) url: "/events/chat" id: "chat-container")(sg-form (list (sg-text-field name: "text" placeholder: "What needs doing?") (sg-submit-button label: "Add")) action: "/items" id: "add-form") ; POST by default```## Higher-Level Components## Form Fields### Modal Dialogs`sg-text-field`, `sg-email-field`, `sg-password-field`, `sg-hidden-field`,`sg-textarea-field`, `sg-select-field`, `sg-checkbox-field`, and the underlying`sg-input-field`. Non-string values are coerced (numbers stringified, `#f` omitsthe attribute); `label:`/`error:` wrap the field in a `<div>` with a `<label>`.```scheme;; Define a modal(sg-modal (list (p "Are you sure you want to delete this item?") (sg-button "Delete" action: "/api/delete/42" method: "delete") (sg-modal-close "Cancel")) id: "confirm-modal" title: "Confirm Delete");; Trigger button(sg-modal-trigger "Delete Item" target: "#confirm-modal")(sg-text-field name: "title" value: some-value placeholder: "Title" autofocus: #t)(sg-select-field name: "genre" options: '(("rock" . "Rock") ("jazz" . "Jazz")) value: "jazz")(sg-submit-button label: "Save")```### Data TablesRender tabular data with optional row actions. Column field values are extracted from dicts or alists. Action URLs support `{field}` placeholders interpolated from row data.```scheme(sg-data-table columns: '((name "Name") (email "Email")) rows: users row-actions: (list (sg-table-action "Edit" action: "/users/{id}/edit" method: "get") (sg-table-action "Delete" action: "/users/{id}" method: "delete" confirm: "Delete this user?")) empty-message: "No users found.")```## Widgets### Paginator- `flash-message type: message: (keys: remove-after:)` — a `role="alert"` box.- `sg-loading-indicator` — a spinner element toggled during actions.- `sg-modal` / `sg-modal-trigger` / `sg-modal-close` — `<dialog>`-based modals.- `sg-data-table columns: rows: (keys: row-actions:)` + `sg-table-action`.- `sg-paginator current-page: total-pages: base-url: (keys: target:)`.- `sg-sse children (keys: url:)` — an SSE-subscribed container.Render pagination navigation with prev/next links and page numbers. Uses `sg-link` internally for SSE morphing.## Deprecated Aliases```scheme(sg-paginator current-page: 3 total-pages: 10 base-url: "/users" target: "#user-list" params: '((sort . "name")))```The UI-update family shipped as `sse-*`; the current names are `ui-*`. Thesealiases still work (same bindings) through 1.0 — prefer the `ui-*` names in newcode:Keywords: `current-page:`, `total-pages:`, `base-url:`, `target:`, `params:` (extra query params), `window-size:` (pages shown around current, default 2).- `sse-morph` → `ui-update`; `sse-remove`/`sse-class`/`sse-eval`/`sse-redirect`/ `sse-reload`/`sse-css-reload`/`sse-js`/`sse-flash` → the matching `ui-*`.- `sse-response-batch` → `ui-response`.- `sigil-ui-response target: content: (keys: (mode) (status))` is the older single-fragment actor response (HTML body + `Sigil-UI-Merge-*` headers). It still works; prefer `ui-response` with a `ui-update` for one consistent model.### Flash Messages## Common PatternsDisplay temporary notifications with auto-removal.### A form that resets itself and broadcasts a new row```scheme;; Create a flash message element(flash-message type: 'success message: "Record saved!" remove-after: 3000);; Send via SSE (appends to #sg-flash-container)(sse-flash type: 'error message: "Validation failed")(define (create-handler req) (let ((text (assoc-ref 'text (parse-form-data req)))) (when (and text (> (string-length text) 0)) (broadcast-send hub ; everyone: append the row (ui-update target: "#list" mode: "append" content: (item-row (add-item! text))))) (ui-response (ui-update target: "#add-form" content: (add-form))))) ; you: reset the form```Types: `'info`, `'success`, `'error`, `'warning`. Each gets a CSS class like `sg-flash-success`.### Loading Indicator### A full page, wired up, in one handler```scheme(sg-loading-indicator id: "spinner")(sg-loading-indicator id: "spinner" active: #t)```## Common Patterns### Form with Validation Errors(define (home-handler req) (http-response/page "To-Do" `(div (h1 "To-Do") ,(add-form) (ul (@ (id "list")) ,@(map item-row (all-items))))))```scheme(define (login-form . errors) (sg-form (list (sg-email-field name: "email" label: "Email" error: (assoc-ref 'email errors #f)) (sg-password-field name: "password" label: "Password" error: (assoc-ref 'password errors #f)) (sg-submit-button label: "Sign In")) action: "/login" method: "post" target: "#login-form"))(define (login-handler request) (let ((errors (validate-login request))) (if errors (sigil-ui-response target: "#login-form" mode: "morph" content: (login-form errors)) (sigil-ui-redirect url: "/dashboard"))))(define app (-> (routes (router (route method: GET pattern: "/" handler: home-handler))) (with-sigil-ui) (with-logging) (with-not-found)))```### Real-Time Updates```scheme;; Page with SSE connection(define (chat-page request) (http-response/html 200 (layout "Chat" (list (sg-sse '((div (@ (id "messages")))) url: "/events/chat") '(div (@ (id "sg-flash-container")))))));; SSE handler pushes updates(define (chat-event-handler request) (sse-response-batch (sse-morph target: "#messages" mode: "append" content: `(div (@ (class "msg")) ,new-message)) (sse-eval cmd: "scroll-to" target: "#messages" position: "bottom")))```For live lists and streams built on this vocabulary, see `(sigil web live)`(`live.md`).