Commit Graph

4141 Commits

Author SHA1 Message Date
1e2ff38759 wasm: rebuild browser kernel — consistent loader + .assets + .sxbc modules
The deployed sx_browser.bc.wasm.js referenced content-hashed .wasm binaries that
weren't on disk (partial build), so the kernel 404'd and the SPA died site-wide.
Rebuilt via sx_build target=wasm and committed the matching artifacts so a git
checkout can't re-introduce the mismatch. (Staged only shared/static/wasm/;
other worktree changes left untouched.)
2026-07-02 11:54:58 +00:00
47a88ea158 otel: waterfall prefers newest multi-span trace (real page render) 2026-07-02 11:49:52 +00:00
6c2a6ccf07 otel: waterfall shows latest REAL trace (skip dashboard's own poll routes) 2026-07-02 11:48:05 +00:00
76941277fd otel: child spans in the blog render path (waterfall breakdown) 2026-07-02 11:44:00 +00:00
ca80df9ade otel: hover tooltips on waterfall bars (SVG <title>) 2026-07-02 11:00:40 +00:00
4f17a40187 otel: relative 'Ns ago' timestamp on recent traces 2026-07-02 10:53:02 +00:00
e49229c20b otel: sustained SPA poll via a hidden ticker sibling (browser-verified) 2026-07-02 09:34:20 +00:00
2785a14ece otel: robust SPA poll — stable #otel-body polls /otel/fragment (innerHTML) 2026-07-01 20:48:15 +00:00
44d29866e7 otel: wire /otel into the SPA (dual-mode SX + sx-trigger poll refresh) 2026-07-01 20:31:44 +00:00
7754666de1 otel: waterfall time ruler + recent-traces show actual target path & duration 2026-07-01 20:18:40 +00:00
322ff4f691 otel: funky dashboard (latency bar chart + status-colored waterfall) + boot self-warm
Dashboard gains a per-route latency bar chart (nested p50/p95/p99 bars, tail
visible) + status-colored waterfall with ms duration labels + a real 3s
auto-refresh (replacing the non-functional data-on-load SSE attr). serve.sh
self-warms the serving JIT over /dev/tcp so the first visitor after a restart
gets ~78ms not the one-time ~2.5s compile. otel suite 125/125.
2026-07-01 19:41:40 +00:00
fa1afd7b5d host: mount otel/routes before blog-routes so GET /otel isn't swallowed by /:slug
The otel dashboard route (GET /otel) is single-segment, so blog-routes' /:slug catch-all
shadowed it (404 'no post: otel'); only /otel/stream (two segments) survived. Move otel/routes
ahead of the blog routes. Live-only wiring fix (route order); no test change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:50:25 +00:00
0d302b8a85 otel: wire into live boot — load otel.sx + mount otel/routes in serve.sh
Adds lib/host/otel.sx to serve.sh MODULES and otel/routes to the host/serve
group list so GET /otel (+ /otel/stream) serve on the live host once merged.
Build-time wiring only; no container touched.
2026-07-01 18:20:46 +00:00
351131e92b otel: tick P8, log progress — roadmap P1-P8 complete (124/124) 2026-07-01 18:20:46 +00:00
3d9dc832fc otel P8: W3C traceparent propagation + error spans
otel/format-traceparent + otel/current-traceparent emit '00-<32hex>-<16hex>-01';
otel/parse-traceparent round-trips it (nil on malformed/bad-width). otel/-timed
now guards the thunk: success spans get :status ok, a raised error records a
span with :status error + an exception event then propagates. Error propagation
uses a false-returning guard clause test (an explicit (raise e) in a guard
handler re-enters the guard and hangs).
2026-07-01 18:20:46 +00:00
b478d0a8da otel: tick P7, log progress 2026-07-01 18:20:46 +00:00
84285d23e9 otel P7: OTLP-JSON export + injected transport
otel/export-otlp folds spans → OTLP/JSON envelope (resourceSpans → scopeSpans →
spans) with hex traceId(32)/spanId(16)/parentSpanId, uint64-as-string nano
timestamps, typed attributes (stringValue/intValue), and span kind
(SERVER/INTERNAL). otel/export-otlp-json encodes via dream-json-encode;
otel/post-otlp POSTs through an injected transport (testable without a live
collector).
2026-07-01 18:20:46 +00:00
4e201ad107 otel: tick P6, log progress 2026-07-01 18:20:46 +00:00
4400870abe otel P6: live dashboard — GET /otel SSR + /otel/stream SSE
otel/dashboard SSRs the metrics strip + latest-trace waterfall + recent-traces
list as HTML carrying Datastar-style data-on-load subscribing to /otel/stream,
the SSE feed of SXTP otel.span events. Routes otel/dashboard-route +
otel/stream-route (otel/routes) mount via make-app. recent-traces/latest-trace
+ otel/span-event helpers.
2026-07-01 18:20:46 +00:00
296fa45bea otel: tick P5, log progress 2026-07-01 18:20:46 +00:00
c273467929 otel P5: metrics aggregate-fold (per-route counts + p50/p95/p99)
otel/metrics folds spans → {:total-requests :routes}; each route carries a
request count and nearest-rank latency percentiles over its durations. Route
key is the http.route attr (falls back to span name). Includes a small
insertion sort (no sort primitive) and order-preserving distinct.
2026-07-01 18:20:46 +00:00
41c62f0c8c otel: tick P4, log progress + splice-unquote gotcha 2026-07-01 18:20:46 +00:00
5f06b5e8e0 otel P4: render-fold → SVG waterfall
otel/waterfall-rects folds a trace's spans into rect geometry (x by start
offset, width by duration, y by depth via parent-link ancestor count);
otel/waterfall folds those into an inline <svg> (one <rect>+<text> per span).
Renders to real SVG markup via the html tag registry.
2026-07-01 18:20:46 +00:00
06294e964c otel: tick P3, log progress + pre-existing env note 2026-07-01 18:20:46 +00:00
c2def0ea16 otel P3: auto-instrument handlers at the make-app seam
otel/instrument-routes wraps each flattened Dream route's handler in a timed
span named METHOD /route with {:http.method :http.route :http.status} attrs;
host/make-app applies it so every matched request becomes a trace. Refactored
with-span onto a shared otel/-timed core that takes a finalize fn for
result-derived attrs (the http.status only known post-handler).
2026-07-01 18:20:46 +00:00
e521909b21 otel: tick P2, log progress 2026-07-01 18:20:46 +00:00
51d4224a55 otel P2: now-ns wraps host clock-milliseconds as epoch nanoseconds
Clamp against a high-water mark so the clock never steps backwards; span
durations stay non-negative. Real ns-scale timestamps replace the P1
placeholder counter.
2026-07-01 18:20:46 +00:00
c8cc4a70dc otel: tick P1, log progress 2026-07-01 18:20:46 +00:00
087c01e890 otel P1: span model + API (with-span, parent stack, ring buffer)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:20:46 +00:00
5535acf4e9 Merge branch 'loops/host' into merge/host-arch
# Conflicts:
#	lib/erlang/runtime.sx
2026-07-01 17:42:08 +00:00
62c9bdd270 host: nt-live-encore seed uses the SX HTML→SX converter (drops the Python one-off)
host/blog-seed-nt-live-encore! now embeds the RAW Ghost HTML (from rose-ash.com/rss) and
imports via the "html" field, so host/html->sx converts it at boot — no more pre-converted
sx_content from the external Python script. Verified: the converter produces the identical 11
cards (card-image/text ×4 pairs + 3 card-embed), handling the real post's kg-card comments,
srcset, and nested figcaption markup. blog 197/197.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:49:43 +00:00
7e2275b90c host: SX-native HTML→SX converter (the radar migrator) + first-class HTML import
lib/host/htmlsx.sx — a pure-SX HTML → SX converter (char-level tokenizer + stack parser):
host/html->sx turns a post's HTML into an (article …) tree that host/blog--decompose! consumes
— img / p / figure+figcaption / iframe / headings / blockquote / lists, inline strong/em/a kept
nested (decompose flattens to text), entities decoded to UTF-8, comments+doctype skipped. This
replaces the one-off external Python converter used for the nt-live-encore import.

import-post! now accepts a raw "html" field (converted via html->sx, serialized to sx_content,
decomposed) alongside "sx_content" — so importing real Ghost HTML is first-class. Wired
htmlsx.sx into conformance.sh + serve.sh module lists (loads in conformance AND live).

New htmlsx suite 8/8 (text/entities/void/nested/figure/iframe/comments + an html→sx→decompose→
typed-cards round-trip); blog 197/197 (+ import-from-html test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:32:06 +00:00
a99e64b661 host: live 2-field composition demo (Landing type: :body + :aside)
host/blog-seed-landing-demo! (+ host/blog--seed-card! fixed-slug helper): a Landing TYPE with
TWO composition fields — :body (heading/text/image + cond/each) and :aside (text/callout, no
controls) — plus a populated landing-demo instance, wired into serve.sh (survives wipes),
idempotent (fixed card slugs, set-comp! overwrites). /landing-demo/ renders both fields; its
edit page shows two independent block editors (#comp-body, #comp-aside); /landing/ reads the
two-field definition. Demonstrates layer 2 end to end on the live site.

blog 196/196 (+ tests: idempotent 2-field seed, both fields render).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:21:10 +00:00
fc7ec99037 host: type pages are self-documenting — definition + POPULATION
Every type post reads as schema + extension. Added host/blog--type-population (host/blog--take
helper): a type's page shows its instances (posts is-a it, first 24 + count) and its subtypes
(is-a / subtype-of inverses), next to the read-only type definition. Injected in host/blog-post
when host/blog--is-type?. So /article/ shows what an article IS *and* which posts are articles;
/card/ shows its subtypes; every card type / tag / type reads its own definition (all are
is-type?).

blog 194/194 (+ tests: population lists instances + count, a parent type lists subtypes, GET
/article/ shows Population).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:17:51 +00:00
5968c0173f regex string-pattern API + test-harness host-call-fn mock
Ported from loops/sx-vm-extensions 4ab9db05 + 8ec36b31.

- sx_primitives.ml (shared serving binary): regex-replace/split/match/etc. accept
  a raw pattern string (auto-compiled) as well as a compiled regex dict. Fixes
  (regex-replace "[0-9]" "_" s) / (regex-split "[ \t]+" s) which required a dict.
- run_tests.ml (test harness only): bind host-call-fn (= apply) + host-call-fn-raising
  / host-new-function / host-iter? / host-to-list in the mock DOM block. Recovers 712
  hyperscript behavioral tests that died on "Undefined symbol: host-call-fn"
  (run_tests --jit 1073 → 361 failures). No serving impact (test binary only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:46:01 +00:00
92b8007a76 host: read the type definition on a type's PUBLIC page
A type post's public page (/article/) now shows a read-only Type-definition panel: its fields,
each Composition field's block grammar ("may contain: heading, text, image, …; control blocks:
cond, each"), and the relations its instances may use — so anyone can read what a type IS, not
just admins on the edit page. host/blog--type-def-view (the read form of host/blog--type-def-
editor's data); injected in host/blog-post after the body when host/blog--is-type?.

blog 191/191, full conformance 420/420 (+ tests: the view renders fields/grammar/relations;
GET /article/ shows it, an instance's page doesn't).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:32:47 +00:00
7838e45aea host: Part B — relations are type-governed composition too
related / is-a / subtype-of / tagged are part of an object's composition (external — NOT in the
CID), and the TYPE declares which relation kinds its instances may use (:type-relations; absent
-> all kinds, so metamodel types keep full freedom). host/blog--{all-rel-kinds, type-relations,
set-type-relations!, allowed-relations, relation-allowed?}. The relation editors filter to the
permitted kinds; relate-submit ENFORCES it. article declares (related is-a tagged) — an article
instance can't be subtyped. The type-def editor (Part C) gains a relation CHECKLIST + POST
/<type>/relations, so the type's inline block-grammar AND external relations are edited in one
place: "it's just more composition."

blog 189/189 (+ Part B tests: allowed-relations excludes subtype-of for article, editors filter,
relate rejects a forbidden kind, checklist renders, POST /relations sets it). Full conformance
deferred — the sibling OTel loop is contending on the shared warm-conf dir; Part B touches only
blog.sx, so the other 7 suites are unaffected. Verifying live instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:22:41 +00:00
30a23d4dae host: Part C — edit the TYPE DEFINITION (its grammar) on the type's own page
"It's just more composition": a type post's edit page now shows a Type-definition editor —
each field as name:type, and each Composition field with a GRAMMAR CHECKLIST (a checkbox per
card kind = permitted, + conditional/repeater toggles). Editing it changes what the type's
instances may contain. host/blog--{is-type?, set-field-grammar!, own-field, checkbox,
grammar-form, type-def-editor}; POST /<type>/grammar reads the checklist (uniquely-named
blk-<ct> / allow-<ctrl> boxes, since form fields are single-value) → set-field-grammar!.
Shown only when host/blog--is-type? (declares fields, or subtype-of type) — a type's page has
it, an instance's doesn't.

blog 184/184, full conformance 413/413 (+ Part C tests: is-type?, set-field-grammar!, the
checklist renders, POST /grammar sets it, appears on a type page not an instance's).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:03:43 +00:00
10243113dc host: Part A — type-block GRAMMAR (a Composition field declares which blocks it permits)
The type now GOVERNS the composition, not just declares the slot. A Composition field carries
its grammar: {:name "body" :type "Composition" :blocks (…card types…) :allow ("cond" "each")}.
:blocks absent -> any card subtype (back-compat); :allow absent -> both control blocks.

- host/blog--{field-decl, allowed-blocks, allows-control?, block-allowed?, comp-violations}.
- The editor PALETTE is the grammar: one <option> per allowed card type (spliced as direct
  <select> children), and the conditional/repeater add-forms appear only if :allow permits.
- block-add-submit ENFORCES it (was a coarse "any card subtype" check) — the type governs writes.
- comp-violations flags a composition holding a forbidden block (the save/import gate).
- article declares its :body grammar (all 7 card kinds + cond/each).

blog 179/179, full conformance 408/408 (+ grammar tests: allowed-blocks/allows-control?,
palette shows only permitted kinds, add rejects a forbidden card, violations flags one).
Part B (relations as type-governed composition) + Part C (edit the type definition) next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:56:30 +00:00
e308a7082e host: seed nt-live-encore so the real import survives store wipes
host/blog-seed-nt-live-encore! imports the real post (its HTML-derived sx_content embedded)
via host/blog-import-post!, decomposing it into the :body composition of typed cards; wired
into serve.sh next to the demo seeds. Verified: after a full store wipe + reboot it reseeds
(HTTP 200, 4 images, 3 video embeds, tagged nt-live/films). Idempotent. blog 175/175.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:37:31 +00:00
a8c095b1b3 host: article declares :body composition + decompose real posts (figure/iframe→cards)
Prep for importing a real blog post into the :body composition:
- article now DECLARES {:name "body" :type "Composition"} (layer 2 — the type defines that an
  article's body is a composition). The edit FORM + submit read scalar-fields only, so the
  Composition field never gets a stray text input (or gets nil'd on save).
- decompose handles real-post block kinds: <figure> → card-image WITH its <figcaption> as the
  caption (host/blog--find-child digs out the inner <img>); <iframe>/<embed>/<video> →
  card-embed with src as :url. card-embed's template now renders an actual <iframe> (videos
  play) instead of the url as text.

blog 175/175, full host conformance 404/404 (+ test: figure→card-image(caption) & iframe→
card-embed via import). Next: wipe content (reseed types+demos), import nt-live-encore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:35:29 +00:00
616c3cf966 host: layer 2 — types declare composition fields (a block editor per field)
:body was hardwired; now a TYPE declares which of its fields are compositions
({:name "body" :type "Composition"}), and an object may carry several (:body, :aside, :body-1).
The edit page renders ONE block editor per declared field (host/blog--block-editors →
host/blog--composition-fields → the type's Composition fields, default ["body"]); each editor
is independent, targets #comp-<field>, and its cards get field-qualified slugs
(<container>__<field>__<name>). Every block op takes a `field` (threaded via a hidden "field"
input, so routes are unchanged); the response re-renders just that field's editor.

STORAGE: compositions moved into a STRING-KEYED sub-dict :comps (like :field-values) —
string keys round-trip through persist cleanly, whereas a mix of a keyword :body and a string
"body" top-level key does NOT survive serialization as one key (it splits the data). body-of/
set-body! delegate to comp-of/set-comp! with "body" + a legacy top-level :body read fallback,
so existing bodies still render (the demos reseed into :comps on boot).

blog 174/174, full host conformance 403/403 (+ tests: a Landing type with two Composition
fields → two independent #comp-body/#comp-aside editors; block-add! to a named field; default
[body]). Editor still renders any node kind (no "unknown block"); #block-editor wrapper kept
so the Playwright selectors hold.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:07:21 +00:00
b056469be1 host: block editor renders ANY composition node (no more "(unknown block)")
The block editor assumed cards-as-objects leaves (ref/alt-with-refs/each-with-ref), so a
hand-authored composition (the compose-demo: text/row/alt-with-text/each-with-inline) fell
through to "(unknown block)" for every text/row node. Now every node kind gets a labelled row
+ preview + move/remove controls: card (✎ chip), text (its content), layout (row/grid + item
count), field, group, and a graceful "other". Conditionals/repeaters display each branch via
host/blog--node-display (a ref → ✎ chip, else the inline text/summary) instead of assuming a
ref. host/blog--node-kind extended (text/layout/field/group); +node-display/+branch-display.

TEST-FIRST: a mixed body (text + alt-with-text + row + each-with-inline) asserts the editor
has NO "unknown block" and labels text/layout/for-each. RED before, GREEN after. blog 171/171.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:15:30 +00:00
39c3def2e7 host: composition editor for and/or/each + relative-addressed refs (resolve-in-context)
The block editor now edits the object's ONE root composition (:body) as three block kinds —
CARD (a ref leaf, the "and"/content), CONDITIONAL (alt+when, the "or": render the first
branch whose live-context condition holds), and REPEATER (each: render a template per graph
query). The render-fold already interprets seq/alt/when/each/ref, so authored compositions
render for free; this adds the editing model + UI.

ADDRESSING (per the design discussion — refs are IPNS-like, not frozen CIDs): refs are
RELATIVE-STORED + RESOLVE-IN-CONTEXT. A :body stores (ref "body__b0") (field-relative); the
render context carries the CONTAINER (the object being rendered) and the resolver combines
them -> the card's storage slug <container>__<field>__<name>. So a body is portable (doesn't
pin the container's name), and editing a card updates everything that refs it for free (no
cascade). A cross-domain ref is absolute with an authority ("market:…"); the resolver
dispatches on the prefix (local today, fetch_data/AP later). A compat shim resolves an older
absolute ref directly. (Snapshot-to-absolute-CID stays a future on-demand op; the CID —
hash(record incl :body) — is the immutable layer over this naming layer.)

MODEL: host/blog--{card-slug,resolve-ref,slug->ref,new-card!,node-kind,node-refs,node-pred,
node-each-type,cond->pred,pred->ckey}; block-add!/add-cond!/add-each!; index-addressed
block-move-idx!/remove-idx!/set-cond! (alt/each aren't single refs). UI: host/blog--block-row
renders by kind (card / "if <cond> → … else → …" / "for each <type> → …") with a condition
<select> + ✎ links to each card's own /<cslug>/edit (external object, CID-neutral). Routes:
POST /:slug/blocks/{add, add-cond, add-each, :idx/{move,remove,cond}}.

Types-define-structure is the next layer (a type declares its composition field(s) + block
grammar). Full host conformance 399/399 (blog 170, incl. 5 new and/or/each tests: add-cond/
add-each/set-cond, a conditional rendering the context-chosen branch, the 3-form editor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:08:12 +00:00
01e0b5db41 host: block-editor card-type <select> options are direct children (populate on boosted nav)
The add-block dropdown wrapped its <option>s in a <span> — (select :name "ctype" (span
(option…)…)) — to splice a dynamic list. A <select> only renders <option>/<optgroup> direct
children, so the dropdown was empty. A full-page load hid it (the browser's HTML parser hoists
mis-nested options out of the select), but on a BOOSTED nav the DOM is built programmatically
(no parser error-recovery), so the span stayed and the dropdown was empty. The card types are
a fixed set — inline the options directly as <select> children.

TEST-FIRST: 4th boost-nav.spec.js case (LOGGED IN: boosted nav to edit → assert
select[name=ctype] > option count is 5, incl card-heading). RED before (0 direct-child
options — span-wrapped), GREEN after. All 4 boost-nav tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:06:57 +00:00
7bec86289c web: morph preserves the boost's injected sx-* attrs — fix edit-page swap clobbering #content
Reported: logged in, go to an edit page, then press Home — nothing happens.

Root cause (browser-DOM trace): a boosted nav home→post morphs a home footer <a> into the
post's "edit" link (morph reuses nodes positionally). morph-node's sync-attrs then STRIPS any
attribute the old node has but the SERVER node lacks — which removes the boost's
client-injected sx-swap="innerHTML" (the server never sends it). With sx-swap gone the swap
defaults to outerHTML, so clicking edit REPLACES #content (the <div id=content>) with the edit
fragment's <div> (no id) — DOM trace: "sx-boost children [NAV, DIV#content]" → "[NAV, DIV]".
#content is destroyed, so every later boosted nav (Home) fetches but has no swap target
("post-swap: root=nil") → nothing updates.

Fix: sync-attrs no longer removes the boost's injected navigation attributes (sx-target /
sx-swap / sx-push-url / sx-get / sx-select) when the new (server) node lacks them — they're
identical across all boosted links, so a reused node keeps sx-swap="innerHTML" and the swap
morphs #content's children instead of replacing #content. Recompiled the web stack. Pairs
with a511b21d (fresh href) + 88f8b427 (SX-Redirect) — three facets of the morph-node-reuse
problem (stale href, lost swap attr, guarded-redirect clobber).

TEST-FIRST: added a 3rd boost-nav.spec.js case (LOGGED IN: home→post→edit, assert #content
survives + Home works). Reproduced RED via DOM traces (#content count 0), GREEN after — on the
ephemeral server AND live. No regressions: picker 3/3 + block-editor 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:17:59 +00:00
88f8b427c5 host: guarded route via boost → SX-Redirect (full nav to /login), not a #content-clobbering 303
Reported: go to an edit page, then press Home — nothing happens; navigation stops updating.

Root cause (found via a browser trace): a guarded route (host/require-login) answered a
BOOSTED (SX-Request) request with a 303 to /login. The browser's fetch follows the redirect
but DROPS the SX-Request header on the way, so /login returned the full HTML shell (<!doctype
html>…), not a text/sx fragment. Morphing that whole document into #content DESTROYS the
#content swap target (diagnostic: "#content count: 0"), so every later boosted nav fetches
but has nowhere to swap ("post-swap: root=nil") — the persistent nav Home appears to do
nothing.

Fix (host-side, no engine change — the engine already supports SX-Redirect): for a boosted
request require-login now returns 200 + an `SX-Redirect: /login?next=…` header. The engine
does a FULL navigation (browser-navigate) to a real /login page — #content is never
clobbered. Non-boosted requests still get a plain 303. Also added a "← Home" link to the
login shell (it's a standalone page with no persistent nav, so a logged-out user who followed
a guarded link was otherwise stranded — the literal "press Home" case).

TEST-FIRST: added a second boost-nav.spec.js case (home → post → click edit → assert clean
full-nav to /login, NOT a clobbered SPA, and Home works from there). Confirmed RED before
(Home did nothing on the clobbered page), GREEN after — verified on the ephemeral server AND
live. No regressions: picker 3/3 + block-editor 1/1 (login flow intact).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:55:32 +00:00
a511b21dd2 web: boosted links read href FRESH at click time — fix stale nav after a morph swap
Reported: on blog.rose-ash.com, home --boosted nav--> a post --click "edit"--> lands on
/tags (a HOME footer link), not /<slug>/edit; subsequent navs stop updating.

Root cause: an innerHTML boost swap uses morph-children, which REUSES DOM nodes in place
(matched positionally when links have no id). The home footer's <a href="/tags"> element is
re-purposed as the post's <a href="/compose-demo/edit"> — its href attribute is rewritten,
but bind-client-route-click had captured the OLD href in its click closure, and the element's
is-processed? mark survived the morph (so boost-descendants skipped re-binding it). Clicking
the reused "edit" link fired the stale /tags closure.

Fix: bind-client-route-click now reads the href FRESH from the element at click time
(dom-get-attr link "href", falling back to the captured value) instead of trusting the
closure. A reused node then always follows its CURRENT href — robust to morph reuse without
needing to clear marks or remove listeners. Recompiled the web stack (.sxbc + manifest).

TEST-FIRST: lib/host/playwright/{boost-nav.spec.js, run-boost-nav-check.sh} reproduces the
exact flow (home -> boosted nav -> click edit -> assert URL is /compose-demo/edit, NOT /tags)
against an ephemeral server. Confirmed RED before the fix (landed on /tags), GREEN after. No
regressions: relate-picker 3/3 (incl. boosted-nav populate) + block-editor 1/1 still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 05:55:46 +00:00
f804a71726 host: block editor live-swap — :sx-post (not sx-disable) + a Playwright check
The block-editor move/remove controls used :sx-disable "true" (the OLD relate-picker pattern
= plain POST → 303 → full reload). Switched to :sx-post + :sx-target #block-editor + :sx-swap
outerHTML (the current pattern): the click is a text/sx form round-trip through the WASM
engine, the handler returns the re-rendered #block-editor, and it swaps IN PLACE — no reload.

Added lib/host/playwright/{block-editor.spec.js, run-block-check.sh} (the run-picker-check
harness pattern: ephemeral host server + one editable post + the main worktree's chromium).
Verifies the irreducibly-browser behaviour the SX conformance can't see: adding, reordering
(↑), and removing blocks re-render #block-editor live, and the controls RE-BIND on the
content each swap brings in. PASSES (1/1, 16s). blog conformance still 165/165.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 05:20:52 +00:00
af3d81d108 host: polish — a third fold domain (deps) + a live execute-fold demo (/workflow-demo)
Two concrete demonstrations of the composition architecture:

THIRD DOMAIN (proves step 8's "a new domain is just a dict + leaf, no new control flow").
host/comp-deps folds a composition to the object ids it TRANSCLUDES — the static contains
DAG of a body. It reuses host/comp-fold's seq/alt/each dispatch verbatim; only the leaf
(collect `(ref ID)`) + accumulator (concat) are new. Useful in its own right (what a
(seq (ref c0) (each … (ref …))) body pulls in; context-specific — alt picks the taken
branch). compose suite 20/20.

LIVE EXECUTE-FOLD DEMO (makes step 7 tangible, parallel to /compose-demo for render).
/workflow-demo runs ONE composition object's :body through host/exec-run — the SAME structure
the render-fold would turn into HTML, folded by execute into a plan of effects (validate →
branch on status → notify each recipient). host/blog-seed-workflow-demo! + host/blog-workflow-
demo + route + serve.sh seed. Shows the behaviour model IS an execute-fold over a composition
object — the same object the block editor authors. blog suite 165/165.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 05:16:56 +00:00