Update reference docs: fix event names, add demos, document sx-boost target

- Remove sx:afterSettle (not dispatched), rename sx:sendError → sx:requestError
- Add sx:clientRoute event (Phase 3 client-side routing)
- Add working demos for all 10 events (afterRequest, afterSwap, requestError,
  clientRoute, sseOpen, sseMessage, sseError were missing demos)
- Update sx-boost docs: configurable target selector, client routing behavior
- Remove app-specific nav logic from orchestration.sx, use sx:clientRoute event
- Pass page content deps to sx_response for component loading after server fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 23:12:38 +00:00
parent b9003eacb2
commit eee2954559
9 changed files with 333 additions and 117 deletions

View File

@@ -113,7 +113,7 @@ BEHAVIOR_ATTRS = [
("sx-media", "Only enable this element when the media query matches", True),
("sx-disable", "Disable sx processing on this element and its children", True),
("sx-on:*", "Inline event handler — e.g. sx-on:click runs JavaScript on event", True),
("sx-boost", "Progressively enhance all links and forms in a container with AJAX navigation", True),
("sx-boost", "Progressively enhance all links and forms in a container with AJAX navigation. Value can be a target selector.", True),
("sx-preload", "Preload content on hover/focus for instant response on click", True),
("sx-preserve", "Preserve element across swaps — keeps DOM state, event listeners, and scroll position", True),
("sx-indicator", "CSS selector for a loading indicator element to show/hide during requests", True),
@@ -171,10 +171,10 @@ EVENTS = [
("sx:beforeRequest", "Fired before an sx request is issued. Call preventDefault() to cancel."),
("sx:afterRequest", "Fired after a successful sx response is received."),
("sx:afterSwap", "Fired after the response has been swapped into the DOM."),
("sx:afterSettle", "Fired after the DOM has settled (scripts executed, etc)."),
("sx:responseError", "Fired on HTTP error responses (4xx, 5xx)."),
("sx:sendError", "Fired when the request fails to send (network error)."),
("sx:requestError", "Fired when the request fails to send (network error, abort)."),
("sx:validationFailed", "Fired when sx-validate blocks a request due to invalid form data."),
("sx:clientRoute", "Fired after successful client-side routing (no server request)."),
("sx:sseOpen", "Fired when an SSE connection is established."),
("sx:sseMessage", "Fired when an SSE message is received and swapped."),
("sx:sseError", "Fired when an SSE connection encounters an error."),
@@ -585,7 +585,7 @@ EVENT_DETAILS: dict[str, dict] = {
"sx:afterRequest": {
"description": (
"Fired on the triggering element after a successful sx response is received, "
"before the swap happens. The response data is available on event.detail. "
"before the swap happens. event.detail contains the response status. "
"Use this for logging, analytics, or pre-swap side effects."
),
"example": (
@@ -595,42 +595,27 @@ EVENT_DETAILS: dict[str, dict] = {
' :sx-on:sx:afterRequest "console.log(\'Response received\', event.detail)"\n'
' "Load data")'
),
"demo": "ref-event-after-request-demo",
},
"sx:afterSwap": {
"description": (
"Fired after the response content has been swapped into the DOM. "
"The new content is in place but scripts may not have executed yet. "
"Use this to initialize UI on newly inserted content."
"Fired on the triggering element after the response content has been "
"swapped into the DOM. event.detail contains the target element and swap "
"style. Use this to initialize UI on newly inserted content."
),
"example": (
';; Initialize tooltips on new content\n'
'(div :sx-on:sx:afterSwap "initTooltips(this)"\n'
' (button :sx-get "/api/items"\n'
' :sx-target "#item-list"\n'
' "Load items")\n'
' (div :id "item-list"))'
';; Run code after content is swapped in\n'
'(button :sx-get "/api/items"\n'
' :sx-target "#item-list"\n'
' :sx-on:sx:afterSwap "console.log(\'Swapped into\', event.detail.target)"\n'
' "Load items")'
),
},
"sx:afterSettle": {
"description": (
"Fired after the DOM has fully settled — all scripts executed, transitions "
"complete. This is the safest point to run code that depends on the final "
"state of the DOM after a swap."
),
"example": (
';; Scroll to new content after settle\n'
'(div :sx-on:sx:afterSettle "document.getElementById(\'new-item\').scrollIntoView()"\n'
' (button :sx-get "/api/append"\n'
' :sx-target "#list" :sx-swap "beforeend"\n'
' "Add item")\n'
' (div :id "list"))'
),
"demo": "ref-event-after-settle-demo",
"demo": "ref-event-after-swap-demo",
},
"sx:responseError": {
"description": (
"Fired when the server responds with an HTTP error (4xx or 5xx). "
"event.detail contains the status code and response. "
"event.detail contains the status code and response text. "
"Use this for error handling, showing notifications, or retry logic."
),
"example": (
@@ -643,21 +628,22 @@ EVENT_DETAILS: dict[str, dict] = {
),
"demo": "ref-event-response-error-demo",
},
"sx:sendError": {
"sx:requestError": {
"description": (
"Fired when the request fails to send — typically a network error, "
"DNS failure, or CORS issue. Unlike sx:responseError, no HTTP response "
"was received at all."
"was received at all. Aborted requests (e.g. from sx-sync) do not fire this event."
),
"example": (
';; Handle network failures\n'
'(div :sx-on:sx:sendError "this.querySelector(\'.status\').textContent = \'Offline\'"\n'
'(div :sx-on:sx:requestError "this.querySelector(\'.status\').textContent = \'Offline\'"\n'
' (button :sx-get "/api/data"\n'
' :sx-target "#result"\n'
' "Load")\n'
' (span :class "status")\n'
' (div :id "result"))'
),
"demo": "ref-event-request-error-demo",
},
"sx:validationFailed": {
"description": (
@@ -676,6 +662,29 @@ EVENT_DETAILS: dict[str, dict] = {
),
"demo": "ref-event-validation-failed-demo",
},
"sx:clientRoute": {
"description": (
"Fired on the swap target after successful client-side routing. "
"No server request was made — the page was rendered entirely in the browser "
"from component definitions the client already has. "
"event.detail contains the pathname. Use this to update navigation state, "
"analytics, or other side effects that should run on client-only navigation. "
"The event bubbles, so you can listen on document.body."
),
"example": (
';; Pages with no :data are client-routable.\n'
';; sx-boost containers try client routing first.\n'
';; On success, sx:clientRoute fires on the swap target.\n'
'(nav :sx-boost "#main-panel"\n'
' (a :href "/essays/" "Essays")\n'
' (a :href "/plans/" "Plans"))\n'
'\n'
';; Listen in body.js:\n'
';; document.body.addEventListener("sx:clientRoute",\n'
';; function(e) { updateNav(e.detail.pathname); })'
),
"demo": "ref-event-client-route-demo",
},
"sx:sseOpen": {
"description": (
"Fired when a Server-Sent Events connection is successfully established. "
@@ -688,6 +697,7 @@ EVENT_DETAILS: dict[str, dict] = {
' (span :class "status" "Connecting...")\n'
' (div :id "messages"))'
),
"demo": "ref-event-sse-open-demo",
},
"sx:sseMessage": {
"description": (
@@ -698,10 +708,10 @@ EVENT_DETAILS: dict[str, dict] = {
';; Count received messages\n'
'(div :sx-sse "/api/stream"\n'
' :sx-sse-swap "update"\n'
' :sx-on:sx:sseMessage "this.dataset.count = (parseInt(this.dataset.count||0)+1); this.querySelector(\'.count\').textContent = this.dataset.count"\n'
' (span :class "count" "0") " messages received"\n'
' (div :id "stream-content"))'
' :sx-on:sx:sseMessage "this.dataset.count = (parseInt(this.dataset.count||0)+1)"\n'
' (span :class "count" "0") " messages received")'
),
"demo": "ref-event-sse-message-demo",
},
"sx:sseError": {
"description": (
@@ -715,6 +725,7 @@ EVENT_DETAILS: dict[str, dict] = {
' (span :class "status" "Connecting...")\n'
' (div :id "messages"))'
),
"demo": "ref-event-sse-error-demo",
},
}
@@ -1200,14 +1211,22 @@ ATTR_DETAILS: dict[str, dict] = {
"description": (
"Progressively enhance all descendant links and forms with AJAX navigation. "
"Links become sx-get requests with pushState, forms become sx-post/sx-get requests. "
"No explicit sx-* attributes needed on each link or form — just place sx-boost on a container."
"No explicit sx-* attributes needed on each link or form — just place sx-boost on a container. "
'The attribute value can be a CSS selector (e.g. sx-boost="#main-panel") to set '
"the default swap target for all boosted descendants. If set to \"true\", "
"each link/form must specify its own sx-target. "
"Pure pages (no server data dependencies) are rendered client-side without a server request."
),
"demo": "ref-boost-demo",
"example": (
'(nav :sx-boost "true"\n'
';; Boost with configurable target\n'
'(nav :sx-boost "#main-panel"\n'
' (a :href "/docs/introduction" "Introduction")\n'
' (a :href "/docs/components" "Components")\n'
' (a :href "/docs/evaluator" "Evaluator"))'
' (a :href "/docs/evaluator" "Evaluator"))\n'
'\n'
';; All links swap into #main-panel automatically.\n'
';; Pure pages render client-side (no server request).'
),
},
"sx-preload": {

View File

@@ -20,6 +20,10 @@
(defcomp ~reference-events-content (&key table)
(~doc-page :title "Events"
(p :class "text-stone-600 mb-6"
"sx fires custom DOM events at various points in the request lifecycle. "
"Listen for them with sx-on:* attributes or addEventListener. "
"Client-side routing fires sx:clientRoute instead of request lifecycle events.")
table))
(defcomp ~reference-js-api-content (&key table)

View File

@@ -424,7 +424,8 @@
:class "text-violet-600 hover:text-violet-800 underline text-sm"
"sx-target"))
(p :class "text-xs text-stone-400"
"These links use AJAX navigation via sx-boost — no sx-get needed on each link.")))
"These links use AJAX navigation via sx-boost — no sx-get needed on each link. "
"Set the value to a CSS selector (e.g. sx-boost=\"#main-panel\") to configure the default swap target for all descendants.")))
;; ---------------------------------------------------------------------------
;; sx-preload
@@ -727,19 +728,42 @@
"Request is cancelled via preventDefault() if the input is empty.")))
;; ---------------------------------------------------------------------------
;; sx:afterSettle event demo
;; sx:afterRequest event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-after-settle-demo ()
(defcomp ~ref-event-after-request-demo ()
(div :class "space-y-3"
(button
:sx-get "/reference/api/time"
:sx-target "#ref-evt-ar-result"
:sx-swap "innerHTML"
:sx-on:sx:afterRequest "document.getElementById('ref-evt-ar-log').textContent = 'Response status: ' + (event.detail ? event.detail.status : '?')"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Load (logs after response)")
(div :id "ref-evt-ar-log"
:class "p-2 rounded bg-emerald-50 text-emerald-700 text-sm"
"Event log will appear here.")
(div :id "ref-evt-ar-result"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Click to load — afterRequest fires before the swap.")))
;; ---------------------------------------------------------------------------
;; sx:afterSwap event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-after-swap-demo ()
(div :class "space-y-3"
(button
:sx-get "/reference/api/swap-item"
:sx-target "#ref-evt-settle-list"
:sx-target "#ref-evt-as-list"
:sx-swap "beforeend"
:sx-on:sx:afterSettle "var items = document.querySelectorAll('#ref-evt-settle-list > div'); if (items.length) items[items.length-1].scrollIntoView({behavior:'smooth'})"
:sx-on:sx:afterSwap "var items = document.querySelectorAll('#ref-evt-as-list > div'); if (items.length) items[items.length-1].scrollIntoView({behavior:'smooth'}); document.getElementById('ref-evt-as-count').textContent = items.length + ' items'"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Add item (scrolls after settle)")
(div :id "ref-evt-settle-list"
"Add item (scrolls after swap)")
(div :id "ref-evt-as-count"
:class "text-sm text-emerald-700"
"1 items")
(div :id "ref-evt-as-list"
:class "p-3 rounded border border-stone-200 space-y-1 max-h-32 overflow-y-auto"
(div :class "text-sm text-stone-500" "Items will be appended and scrolled into view."))))
@@ -791,3 +815,102 @@
(div :id "ref-evt-vf-result"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Submit with empty/invalid email to trigger the event.")))
;; ---------------------------------------------------------------------------
;; sx:requestError event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-request-error-demo ()
(div :class "space-y-3"
(button
:sx-get "https://this-domain-does-not-exist.invalid/api"
:sx-target "#ref-evt-re-result"
:sx-swap "innerHTML"
:sx-on:sx:requestError "document.getElementById('ref-evt-re-status').style.display = 'block'; document.getElementById('ref-evt-re-status').textContent = 'Network error — request never reached a server'"
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
"Request invalid domain")
(div :id "ref-evt-re-status"
:class "p-2 rounded bg-red-50 text-red-600 text-sm"
:style "display: none"
"")
(div :id "ref-evt-re-result"
:class "p-3 rounded bg-stone-100 text-stone-400 text-sm"
"Click to trigger a network error — sx:requestError fires.")))
;; ---------------------------------------------------------------------------
;; sx:clientRoute event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-client-route-demo ()
(div :class "space-y-3"
(p :class "text-sm text-stone-600"
"Open DevTools console, then navigate to a pure page (no :data expression). "
"You'll see \"sx:route client /path\" in the console — no network request is made.")
(div :class "flex gap-2 flex-wrap"
(a :href "/essays/"
:class "px-3 py-1 bg-violet-100 text-violet-700 rounded text-sm no-underline hover:bg-violet-200"
"Essays")
(a :href "/plans/"
:class "px-3 py-1 bg-violet-100 text-violet-700 rounded text-sm no-underline hover:bg-violet-200"
"Plans")
(a :href "/protocols/"
:class "px-3 py-1 bg-violet-100 text-violet-700 rounded text-sm no-underline hover:bg-violet-200"
"Protocols"))
(p :class "text-xs text-stone-400"
"The sx:clientRoute event fires on the swap target and bubbles to document.body. "
"Apps use it to update nav selection, analytics, or other post-navigation state.")))
;; ---------------------------------------------------------------------------
;; sx:sseOpen event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-sse-open-demo ()
(div :class "space-y-3"
(div :sx-sse "/reference/api/sse-time"
:sx-sse-swap "time"
:sx-swap "innerHTML"
:sx-on:sx:sseOpen "document.getElementById('ref-evt-sseopen-status').textContent = 'Connected'; document.getElementById('ref-evt-sseopen-status').className = 'inline-block px-2 py-0.5 rounded text-xs bg-emerald-100 text-emerald-700'"
(div :class "flex items-center gap-3"
(span :id "ref-evt-sseopen-status"
:class "inline-block px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-700"
"Connecting...")
(span :class "text-sm text-stone-500" "SSE stream")))
(p :class "text-xs text-stone-400"
"The status badge turns green when the SSE connection opens.")))
;; ---------------------------------------------------------------------------
;; sx:sseMessage event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-sse-message-demo ()
(div :class "space-y-3"
(div :sx-sse "/reference/api/sse-time"
:sx-sse-swap "time"
:sx-swap "innerHTML"
:sx-on:sx:sseMessage "var c = parseInt(document.getElementById('ref-evt-ssemsg-count').dataset.count || '0') + 1; document.getElementById('ref-evt-ssemsg-count').dataset.count = c; document.getElementById('ref-evt-ssemsg-count').textContent = c + ' messages received'"
(div :id "ref-evt-ssemsg-output"
:class "p-3 rounded bg-stone-100 text-stone-600 text-sm font-mono"
"Waiting for SSE messages..."))
(div :id "ref-evt-ssemsg-count"
:class "text-sm text-emerald-700"
:data-count "0"
"0 messages received")))
;; ---------------------------------------------------------------------------
;; sx:sseError event demo
;; ---------------------------------------------------------------------------
(defcomp ~ref-event-sse-error-demo ()
(div :class "space-y-3"
(div :sx-sse "/reference/api/sse-time"
:sx-sse-swap "time"
:sx-swap "innerHTML"
:sx-on:sx:sseError "document.getElementById('ref-evt-sseerr-status').textContent = 'Disconnected'; document.getElementById('ref-evt-sseerr-status').className = 'inline-block px-2 py-0.5 rounded text-xs bg-red-100 text-red-700'"
:sx-on:sx:sseOpen "document.getElementById('ref-evt-sseerr-status').textContent = 'Connected'; document.getElementById('ref-evt-sseerr-status').className = 'inline-block px-2 py-0.5 rounded text-xs bg-emerald-100 text-emerald-700'"
(div :class "flex items-center gap-3"
(span :id "ref-evt-sseerr-status"
:class "inline-block px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-700"
"Connecting...")
(span :class "text-sm text-stone-500" "SSE stream")))
(p :class "text-xs text-stone-400"
"If the SSE connection drops, the badge turns red via sx:sseError.")))