From a425ea8ed4bfba8f7ec90de4dca9c358bca7486b Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 11 Mar 2026 20:27:04 +0000 Subject: [PATCH] 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 --- sx/bp/pages/routes.py | 17 +++++++++++++++ sx/sx/layouts.sx | 50 +++++++++++++++++++++++++++++-------------- sx/sxc/home.sx | 14 ++++++++++++ 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/sx/bp/pages/routes.py b/sx/bp/pages/routes.py index e295d96..30be842 100644 --- a/sx/bp/pages/routes.py +++ b/sx/bp/pages/routes.py @@ -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") diff --git a/sx/sx/layouts.sx b/sx/sx/layouts.sx index d9fb8cd..65d0d74 100644 --- a/sx/sx/layouts.sx +++ b/sx/sx/layouts.sx @@ -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")))) - "()")) - ;; 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")))) + "()"))) + ;; 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 diff --git a/sx/sxc/home.sx b/sx/sxc/home.sx index 64d6765..65045d9 100644 --- a/sx/sxc/home.sx +++ b/sx/sxc/home.sx @@ -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"