Marsh demo: video embed with reactive+hypermedia interplay

- ~video-player defisland persists across SPA navigations (morph-safe)
- Clicking "reactive" cycles colour (signal) + fetches random YouTube video (sx-get)
- sx-trigger="fetch-video" + dom-first-child check: video keeps playing on repeat clicks
- Close button (x) clears video via /api/clear-video hypermedia endpoint
- Autoplay+mute removes YouTube's red play button overlay
- Header restructured: logo in anchor, tagline outside (no accidental navigation)
- Flex centering on video container

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 20:27:04 +00:00
parent c82941d93c
commit a425ea8ed4
3 changed files with 65 additions and 16 deletions

View File

@@ -1049,6 +1049,23 @@ def register(url_prefix: str = "/") -> Blueprint:
resp.headers["SX-Retarget"] = "#ref-hdr-retarget-alt"
return resp
# --- Random video (marsh demo: reactive click + hypermedia fetch) ---
_last_video = {"id": ""}
@bp.get("/api/random-video")
async def api_random_video():
from shared.sx.helpers import sx_response
videos = ["mSil_hBqbac", "-EX4mgvnSc8", "maOM81a-Gyg", "OHPJemGxUjE"]
available = [v for v in videos if v != _last_video["id"]]
video_id = random.choice(available)
_last_video["id"] = video_id
return sx_response(f'(~video-embed :video-id "{video_id}")')
@bp.get("/api/clear-video")
async def api_clear_video():
return Response("", content_type="text/html")
# --- Event demos ---
@bp.get("/geography/hypermedia/reference/api/error-500")

View File

@@ -21,18 +21,18 @@
(shade (signal 500))
(current-family (computed (fn ()
(nth families (mod (deref idx) (len families)))))))
(a :href "/"
:sx-get "/" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:style (str (display "block") (max-w (get cssx-max-widths "3xl"))
(mx-auto) (px 4) (pt 8) (pb 4) (align "center")
(decoration "none"))
;; Lake: server can update the logo text
(lake :id "logo"
(span :style (str (display "block") (mb 2)
(cssx (:text (colour "violet" 699) (size "4xl") (weight "bold") (family "mono"))))
"(<sx>)"))
;; Reactive: colour-cycling "reactive" word (signal-bound, NOT in a lake)
(div :style (str (display "block") (max-w (get cssx-max-widths "3xl"))
(mx-auto) (px 4) (pt 8) (pb 4) (align "center"))
;; Logo — only this navigates home
(a :href "/"
:sx-get "/" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
:style (str (display "block") (decoration "none"))
(lake :id "logo"
(span :style (str (display "block") (mb 2)
(cssx (:text (colour "violet" 699) (size "4xl") (weight "bold") (family "mono"))))
"(<sx>)")))
;; Tagline — NOT in anchor. Clicking "reactive" cycles colour + fetches video.
(p :style (str (mb 1) (cssx (:text (colour "stone" 500) (size "lg"))))
"Framework free "
(span
@@ -42,12 +42,18 @@
:on-click (fn (e)
(batch (fn ()
(swap! idx inc)
(reset! shade (+ 400 (* (mod (* (deref idx) 137) 5) 50))))))
(reset! shade (+ 400 (* (mod (* (deref idx) 137) 5) 50)))))
;; Only fetch video if none loaded (marsh: reactive + conditional hypermedia)
(let ((embed (dom-query-by-id "video-embed")))
(when (not (dom-first-child embed))
(dom-dispatch (get e "currentTarget") "fetch-video" (dict)))))
:sx-get "/api/random-video"
:sx-target "#video-embed"
:sx-swap "innerHTML"
:sx-trigger "fetch-video"
"reactive")
" hypermedia")
;; Lake: server morphs this on every navigation — the path updates
;; while the reactive colour above persists. Visible proof that
;; server content flows through the island without disturbing signals.
;; Lake: server morphs copyright on navigation without disturbing signals.
(lake :id "copyright"
(p :style (cssx (:text (colour "stone" 400) (size "xs")))
"© Giles Bradshaw 2026"
@@ -56,6 +62,14 @@
"margin-left:0.5em;")
(str "· " path))))))))
;; Video player island — defisland so the morph algorithm preserves it across
;; navigations. Content swapped in via sx-get from the reactive word click.
;; Empty initially (zero height). Iframe provides height when loaded.
(defisland ~video-player ()
(div :style "display:flex;justify-content:center;"
(div :id "video-embed"
:style "position:relative;width:66%;max-width:20rem;")))
;; @css grid grid-cols-3
;; Current section with prev/next siblings.
@@ -134,6 +148,10 @@
:style (str "opacity:" (+ (* (/ 1 depth) 0.75) 0.25) ";"
"transition:opacity 0.3s;")
(~sx-header :path (or path "/")))
;; Video island — preserved across navigation morphs (like ~sx-header).
;; Outside logo-opacity so it doesn't fade.
;; Marsh demo: reactive click triggers hypermedia fetch, result lands here.
(~video-player)
;; Sibling arrows for EVERY level in the trail
;; Trail row i is level (i+2) of depth — opacity = (i+2)/depth
;; Last row (leaf) gets is-leaf for larger current page title

View File

@@ -1,5 +1,19 @@
;; SX docs — home page components
;; YouTube embed — rendered client-side from video ID returned by /api/random-video.
;; Marsh demo: the server picks the video (hypermedia), the island triggers the fetch (reactive).
(defcomp ~video-embed (&key video-id)
(<>
(button
:sx-get "/api/clear-video" :sx-target "#video-embed"
:sx-swap "innerHTML"
:style "position:absolute;top:-0.5rem;right:-0.5rem;width:1.25rem;height:1.25rem;border-radius:50%;border:none;background:rgba(0,0,0,0.5);color:white;font-size:0.75rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:10;"
"x")
(iframe :src (str "https://www.youtube.com/embed/" video-id "?autoplay=1&mute=1")
:allow "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
:allowfullscreen "true"
:style "width:100%;aspect-ratio:16/9;border-radius:0.5rem;border:none;")))
(defcomp ~sx-hero (&key &rest children)
(div :class "max-w-4xl mx-auto px-6 py-16 text-center"
(h1 :class "text-5xl font-bold text-stone-900 mb-4"