diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 575948d..4ed97d9 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -131,3 +131,57 @@ jobs: } EOF docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile + + # ── redoal.com - second instance, same build ───────────────── + # One binary serves both faces: branding is content-driven + # (site.yaml in each CONTENT_REPO), so only the env differs. + # The compile-time SITE_NAME baked above is just this instance's + # fallback - redoal/questions' site.yaml overrides it. + + - name: Ship redoal release + run: | + set -euo pipefail + rel="/srv/app/redoal-portal/releases/${{ github.sha }}" + mkdir -p "$rel" + cp "$CARGO_TARGET_DIR/release/portal" "$rel/redoal-portal" + cp -r target/site "$rel/site" + ln -sfn "$rel" /srv/app/redoal-portal/current + + - name: Write redoal service env + run: | + cat > /etc/app/redoal-portal.env < /etc/caddy/services.d/redoal-portal.caddy <<'EOF' + www.redoal.com { + redir https://redoal.com{uri} permanent + } + + redoal.com { + reverse_proxy host.docker.internal:3020 + log { + output file /var/log/caddy/redoal.log + } + } + EOF + docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile diff --git a/gesture.js b/gesture.js new file mode 100644 index 0000000..e4121ef --- /dev/null +++ b/gesture.js @@ -0,0 +1,332 @@ +// The `type: gesture` requirement widget: draw one stroke on a canvas, +// and the stroke becomes the field's value. Mirrors the two house +// patterns at once: +// +// - prosekit-editor.js's hidden-input bridge: the widget writes JSON +// into a paired hidden and fires a real bubbling `input` +// event, so the Rust side reuses the exact same RwSignal/on:input +// wiring every other field kind uses - no bespoke value channel. +// - yes.js's lifecycle: mountGesture returns a handle whose stop() +// tears everything down (socket, listeners, rAF), because SPA +// navigation never unloads the document. +// +// When a relay URL is given, the widget also speaks the redoal-relay +// protocol (ADR-0013 in the redoal repo): announce the stroke, receive +// an ack carrying the stroke's gesture key + the key's own decoded +// path ("what the network heard", drawn as a ghost), and receive +// echoes of similar strokes other visitors drew, shown as thumbnails. +// Everything network is best-effort: no relay, refused connection, or +// a dropped socket all degrade to a plain offline drawing input. +// +// Canvas colors can't come from CSS custom properties (same +// constraint yes.js documents), so the widget carries its own copy of +// the palette for both themes - any palette change must land here AND +// in style/main.css. + +const THEMES = { + dark: { + stroke: '#8ec2c0', + ghost: 'rgba(255, 255, 255, 0.28)', + echo: '#8ec2c0', + }, + light: { + stroke: '#47807e', + ghost: 'rgba(29, 29, 27, 0.30)', + echo: '#47807e', + }, +}; + +const MIN_POINT_DISTANCE = 3; // css px between recorded points +const MAX_ECHOES = 8; +const RECONNECT_BASE_MS = 1000; +const RECONNECT_MAX_MS = 30000; + +class GestureWidget { + constructor(container, hidden, relayUrl, hero) { + this.container = container; + this.hidden = hidden || null; + this.relayUrl = relayUrl || ''; + this.hero = !!hero; + this.points = []; + this.ghost = null; + this.ambient = []; // hero mode: echoed strokes drawn in place + this.drawing = false; + this.stopped = false; + this.ws = null; + this.reconnectDelay = RECONNECT_BASE_MS; + this.reconnectTimer = null; + this.pendingAnnounce = null; + + this.canvas = document.createElement('canvas'); + this.canvas.className = 'gesture-canvas'; + container.appendChild(this.canvas); + if (!this.hero) { + this.echoes = document.createElement('div'); + this.echoes.className = 'gesture-echoes'; + container.appendChild(this.echoes); + } + if (this.relayUrl) { + this.status = document.createElement('span'); + this.status.className = 'gesture-status offline'; + this.status.setAttribute('aria-label', 'relay connection'); + container.appendChild(this.status); + } + + this._themeQuery = window.matchMedia('(prefers-color-scheme: light)'); + this._onTheme = () => this.render(); + this._themeQuery.addEventListener('change', this._onTheme); + + this._onResize = () => { this.sizeCanvas(); this.render(); }; + window.addEventListener('resize', this._onResize); + this.sizeCanvas(); + + this._onDown = (e) => this.beginStroke(e); + this._onMove = (e) => this.extendStroke(e); + this._onUp = (e) => this.endStroke(e); + this.canvas.addEventListener('pointerdown', this._onDown); + this.canvas.addEventListener('pointermove', this._onMove); + this.canvas.addEventListener('pointerup', this._onUp); + this.canvas.addEventListener('pointercancel', this._onUp); + + if (this.relayUrl) this.connect(); + } + + theme() { + return this._themeQuery.matches ? THEMES.light : THEMES.dark; + } + + sizeCanvas() { + const rect = this.canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + this.canvas.width = Math.max(1, Math.round(rect.width * dpr)); + this.canvas.height = Math.max(1, Math.round(rect.height * dpr)); + this.ctx = this.canvas.getContext('2d'); + this.ctx.scale(dpr, dpr); + this.cssWidth = rect.width; + this.cssHeight = rect.height; + } + + pos(e) { + const rect = this.canvas.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + beginStroke(e) { + e.preventDefault(); + this.canvas.setPointerCapture(e.pointerId); + // One stroke only - a new pointerdown replaces the old drawing + // (and any ghost/ambient trails from the previous round). + this.drawing = true; + this.points = [this.pos(e)]; + this.ghost = null; + this.ambient = []; + this.render(); + } + + extendStroke(e) { + if (!this.drawing) return; + const p = this.pos(e); + const last = this.points[this.points.length - 1]; + const dx = p.x - last.x, dy = p.y - last.y; + if (Math.sqrt(dx * dx + dy * dy) < MIN_POINT_DISTANCE) return; + this.points.push(p); + this.render(); + } + + endStroke() { + if (!this.drawing) return; + this.drawing = false; + if (this.points.length < 2) return; + const normalized = this.normalized(); + this.setValue({ points: normalized, key: null }); + this.announce(normalized); + } + + // Canvas-frame normalization into the -1..1 square (the relay + // re-normalizes around the stroke itself before keying - this + // just bounds the payload and keeps the stored value + // resolution-independent). + normalized() { + const half = Math.max(this.cssWidth, this.cssHeight) / 2; + const cx = this.cssWidth / 2, cy = this.cssHeight / 2; + return this.points.map((p) => [ + Math.round(((p.x - cx) / half) * 10000) / 10000, + Math.round(((p.y - cy) / half) * 10000) / 10000, + ]); + } + + setValue(value) { + if (!this.hidden) return; + this.hidden.value = JSON.stringify(value); + this.hidden.dispatchEvent(new Event('input', { bubbles: true })); + } + + // ── Relay ────────────────────────────────────────────────────── + + connect() { + if (this.stopped) return; + let ws; + try { + ws = new WebSocket(this.relayUrl); + } catch { + this.scheduleReconnect(); + return; + } + this.ws = ws; + ws.addEventListener('open', () => { + if (this.stopped) return; + this.reconnectDelay = RECONNECT_BASE_MS; + this.status?.classList.replace('offline', 'live'); + if (this.pendingAnnounce) { + ws.send(JSON.stringify({ type: 'announce', points: this.pendingAnnounce })); + this.pendingAnnounce = null; + } + }); + ws.addEventListener('message', (e) => this.onMessage(e)); + ws.addEventListener('close', () => { + this.status?.classList.replace('live', 'offline'); + this.ws = null; + this.scheduleReconnect(); + }); + // 'error' is always followed by 'close'; nothing to do here. + } + + scheduleReconnect() { + if (this.stopped || this.reconnectTimer) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, this.reconnectDelay); + this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS); + } + + announce(points) { + if (!this.relayUrl) return; + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'announce', points })); + } else { + this.pendingAnnounce = points; + } + } + + onMessage(e) { + let msg; + try { + msg = JSON.parse(e.data); + } catch { + return; + } + if (msg.type === 'ack') { + if (this.points.length >= 2) { + this.setValue({ points: this.normalized(), key: msg.key }); + } + this.ghost = msg.path; + this.render(); + } else if (msg.type === 'echo') { + if (this.hero) { + this.ambient.push(msg.path); + if (this.ambient.length > MAX_ECHOES) this.ambient.shift(); + this.render(); + } else { + this.addEchoThumbnail(msg); + } + } + // 'error' frames are intentionally silent: the widget is a + // form field first, and a rate-limited announce shouldn't + // alarm anyone mid-form. + } + + addEchoThumbnail(msg) { + const thumb = document.createElement('canvas'); + thumb.className = 'gesture-echo'; + // Closer strokes render stronger; 500 is comfortably past the + // relay's default threshold, so everything stays visible. + const strength = Math.max(0.35, Math.min(1, 1 - msg.distance / 500)); + thumb.style.setProperty('--echo-strength', String(strength)); + this.echoes.prepend(thumb); + while (this.echoes.children.length > MAX_ECHOES) { + this.echoes.lastChild.remove(); + } + const dpr = window.devicePixelRatio || 1; + const rect = thumb.getBoundingClientRect(); + thumb.width = Math.max(1, Math.round(rect.width * dpr)); + thumb.height = Math.max(1, Math.round(rect.height * dpr)); + const ctx = thumb.getContext('2d'); + ctx.scale(dpr, dpr); + this.drawPath(ctx, msg.path, rect.width, rect.height, this.theme().echo, 1.5, 0.15); + requestAnimationFrame(() => thumb.classList.add('shown')); + } + + // ── Rendering ────────────────────────────────────────────────── + + // Draw a normalized (-1..1) path fitted into w×h with padding. + drawPath(ctx, path, w, h, color, width, pad) { + if (!path || path.length < 2) return; + const scale = (Math.min(w, h) / 2) * (1 - pad); + const cx = w / 2, cy = h / 2; + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + path.forEach(([x, y], i) => { + const px = cx + x * scale, py = cy + y * scale; + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + }); + ctx.stroke(); + } + + render() { + if (!this.ctx) return; + const t = this.theme(); + this.ctx.clearRect(0, 0, this.cssWidth, this.cssHeight); + for (const path of this.ambient) { + this.ctx.globalAlpha = 0.25; + this.drawPath(this.ctx, path, this.cssWidth, this.cssHeight, t.echo, 1.5, 0.2); + } + this.ctx.globalAlpha = 1; + if (this.ghost) { + this.drawPath(this.ctx, this.ghost, this.cssWidth, this.cssHeight, t.ghost, 2, 0.2); + } + if (this.points.length >= 1) { + this.ctx.strokeStyle = t.stroke; + this.ctx.lineWidth = 2; + this.ctx.lineCap = 'round'; + this.ctx.lineJoin = 'round'; + this.ctx.beginPath(); + this.points.forEach((p, i) => { + if (i === 0) this.ctx.moveTo(p.x, p.y); + else this.ctx.lineTo(p.x, p.y); + }); + this.ctx.stroke(); + } + } + + stop() { + this.stopped = true; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + if (this.ws) { + // The close event still fires, but scheduleReconnect + // no-ops once stopped is set. + try { this.ws.close(); } catch { /* already dead */ } + this.ws = null; + } + window.removeEventListener('resize', this._onResize); + this._themeQuery.removeEventListener('change', this._onTheme); + this.canvas.removeEventListener('pointerdown', this._onDown); + this.canvas.removeEventListener('pointermove', this._onMove); + this.canvas.removeEventListener('pointerup', this._onUp); + this.canvas.removeEventListener('pointercancel', this._onUp); + } +} + +export function mountGesture(container, hidden, relayUrl) { + return new GestureWidget(container, hidden, relayUrl, false); +} + +// Hero variant: no form field to mirror into, and echoes render as +// ambient strokes on the drawing canvas itself instead of thumbnails. +export function mountGestureHero(container, relayUrl) { + return new GestureWidget(container, null, relayUrl, true); +} diff --git a/src/app.rs b/src/app.rs index 51ee6c9..15da04f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem}; use crate::auth::{current_user, User}; -use crate::content::{is_qualified, Alternative, Question, Responsible, Transition}; +use crate::content::{is_qualified, Alternative, Question, Responsible, SiteConfig, Transition}; use crate::resource::{get_requirement_binding, get_requirement_options, get_resource}; /// The visible site name/wordmark - "portal" is just this codebase's @@ -83,6 +83,7 @@ fn QuestionPage() -> impl IntoView { let question = Resource::new(move || path.get(), get_question); let user = Resource::new(|| (), |_| current_user()); + let site = Resource::new(|| (), |_| get_site()); view! { impl IntoView { // has already resolved. let user_res = user.get(); let question_res = question.get(); + let site_cfg = site.get().and_then(|r| r.ok()).unwrap_or_default(); question_res .map(|res| match res { Ok(Some(q)) => { let current = user_res.and_then(|r| r.ok()).flatten(); view! { + // A second outranks App's + // SITE_NAME fallback only when content + // actually declares one. + {site_cfg + .title + .clone() + .map(|t| view! { <Title text=t/> })} <QuestionView question=q parent_hash=parent_hash.get() query_email=query_email.get() user=current + site=site_cfg /> } .into_any() @@ -126,6 +136,7 @@ fn QuestionView( parent_hash: Option<String>, query_email: Option<String>, user: Option<User>, + site: SiteConfig, ) -> impl IntoView { let question_id = question.id.clone(); @@ -138,7 +149,8 @@ fn QuestionView( <Hero title=question.name.clone() description=question.description.clone() - show_yes=question_id == "/" + landing=question_id == "/" + site=site.clone() /> <div class="alternatives"> <section class="alt-card gate-card"> @@ -175,7 +187,8 @@ fn QuestionView( <Hero title=question.name.clone() description=question.description.clone() - show_yes=question_id == "/" + landing=question_id == "/" + site=site.clone() /> <div class="alternatives"> <For @@ -347,16 +360,57 @@ mod prosekit { } } +// The `type: gesture` drawing widget (`gesture.js`, crate root): +// prosekit's hidden-input bridge for the value, yes.js's typed-handle +// lifecycle for cleanup - it may own a live WebSocket to a +// redoal-relay, which SPA navigation must close (`stop()`). +#[cfg(feature = "hydrate")] +mod gesture { + use wasm_bindgen::prelude::*; + + #[wasm_bindgen(module = "/gesture.js")] + extern "C" { + pub type GestureWidget; + + #[wasm_bindgen(js_name = mountGesture)] + pub fn mount_gesture( + container: &web_sys::HtmlDivElement, + hidden: &web_sys::HtmlInputElement, + relay_url: &str, + ) -> GestureWidget; + + #[wasm_bindgen(js_name = mountGestureHero)] + pub fn mount_gesture_hero( + container: &web_sys::HtmlDivElement, + relay_url: &str, + ) -> GestureWidget; + + #[wasm_bindgen(method)] + pub fn stop(this: &GestureWidget); + } +} + #[component] -fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView { - // Only the landing page gets the full interactive piece - it's the - // one page this is actually "the" hero for; other pages (thank-you - // pages, /review) get the plain dark header below. Starts the - // animation once the canvas elements exist, and explicitly stops - // the requestAnimationFrame loop on unmount - the original - // page-owning script never needed this since navigating away meant - // a full document unload, which doesn't happen in an SPA. +fn Hero(title: String, description: String, landing: bool, site: SiteConfig) -> impl IntoView { + // Only the landing page gets a full interactive piece - it's the + // one page a hero is actually "the" hero for; other pages + // (thank-you pages, /review) get the plain dark header below. + // WHICH piece is the content repo's call (`site.yaml`'s + // hero.kind): the YES canvas (default - uhhm's look), a redoal + // gesture canvas, or nothing. + let hero_kind = if landing { site.hero.kind.clone() } else { "plain".to_string() }; + let show_yes = hero_kind == "yes"; + let show_gesture = hero_kind == "gesture"; + let wordmark = site.wordmark.clone().unwrap_or_else(|| "/wordmark.svg".to_string()); + let site_title = site.title.clone().unwrap_or_else(|| SITE_NAME.to_string()); + + // Starts the YES animation once the canvas elements exist, and + // explicitly stops the requestAnimationFrame loop on unmount - the + // original page-owning script never needed this since navigating + // away meant a full document unload, which doesn't happen in an + // SPA. let raster_ref: NodeRef<leptos::html::Canvas> = NodeRef::new(); + let gesture_ref: NodeRef<leptos::html::Div> = NodeRef::new(); #[cfg(feature = "hydrate")] if show_yes { // `on_cleanup` requires Send + Sync (even single-threaded, wasm), @@ -391,8 +445,35 @@ fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView { }); } + // The gesture hero mounts the same widget the `type: gesture` + // requirement uses, in hero mode (no form field; echoes render as + // ambient strokes on the canvas itself). Same StoredValue + + // on_cleanup shape as the YES piece - it may own a live WebSocket. + #[cfg(feature = "hydrate")] + if show_gesture { + let relay = site.hero.relay.clone().unwrap_or_default(); + let widget: StoredValue<Option<gesture::GestureWidget>, LocalStorage> = + StoredValue::new_local(None); + Effect::new(move |_| { + let Some(container) = gesture_ref.get() else { + return; + }; + if widget.with_value(|w| w.is_some()) { + return; + } + widget.set_value(Some(gesture::mount_gesture_hero(&container, &relay))); + }); + on_cleanup(move || { + widget.update_value(|opt| { + if let Some(w) = opt.take() { + w.stop(); + } + }); + }); + } + view! { - <header class="hero" class:hero-yes=show_yes> + <header class="hero" class:hero-yes=show_yes class:hero-gesture=show_gesture> {show_yes .then(|| { view! { @@ -402,9 +483,13 @@ fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView { </div> } })} + {show_gesture + .then(|| { + view! { <div class="hero-draw" node_ref=gesture_ref></div> } + })} <div class="hero-copy"> <a class="wordmark" href="/"> - <img src="/wordmark.svg" alt=SITE_NAME/> + <img src=wordmark alt=site_title/> </a> <h1>{title}</h1> <p>{description}</p> @@ -536,6 +621,10 @@ fn AlternativeCard( let mut select_field_map: std::collections::HashMap<String, RwSignal<Vec<String>>> = std::collections::HashMap::new(); let mut select_multi: std::collections::HashMap<String, bool> = std::collections::HashMap::new(); + // Gesture fields share field_map (their hidden input carries a + // JSON string), but submit re-parses them so the stored answer + // holds a real {points, key} object, not a double-encoded string. + let mut gesture_fields: std::collections::HashSet<String> = std::collections::HashSet::new(); for feature in &alternative.features { for req in &feature.requirements { if req.kind == "file" { @@ -546,6 +635,9 @@ fn AlternativeCard( .or_insert_with(|| RwSignal::new(Vec::new())); select_multi.insert(req.name.clone(), req.multiple); } else { + if req.kind == "gesture" { + gesture_fields.insert(req.name.clone()); + } field_map .entry(req.name.clone()) .or_insert_with(|| RwSignal::new(String::new())); @@ -571,6 +663,7 @@ fn AlternativeCard( let file_refs_for_submit = file_refs.clone(); let select_field_map_for_submit = select_field_map.clone(); let select_multi_for_submit = select_multi.clone(); + let gesture_fields_for_submit = gesture_fields.clone(); let question_id_for_transition = question_id.clone(); let alt_name_for_transition = alternative.name.clone(); let on_submit = move |ev: leptos::ev::SubmitEvent| { @@ -597,13 +690,22 @@ fn AlternativeCard( let file_refs_for_submit = file_refs_for_submit.clone(); let select_field_map_for_submit = select_field_map_for_submit.clone(); let select_multi_for_submit = select_multi_for_submit.clone(); + let gesture_fields_for_submit = gesture_fields_for_submit.clone(); let question_id_for_submit = question_id_for_submit.clone(); let alt_name_for_submit = alt_name_for_submit.clone(); let parent_hash_for_submit = parent_hash_for_submit.clone(); leptos::task::spawn_local(async move { let mut map = serde_json::Map::new(); for (name, sig) in field_map_for_submit.iter() { - map.insert(name.clone(), serde_json::Value::String(sig.get())); + let value = if gesture_fields_for_submit.contains(name) { + // The widget's hidden input holds {points, key} as + // JSON; store the object itself (empty/never-drawn + // becomes null, not ""). + serde_json::from_str(&sig.get()).unwrap_or(serde_json::Value::Null) + } else { + serde_json::Value::String(sig.get()) + }; + map.insert(name.clone(), value); } for (name, sig) in select_field_map_for_submit.iter() { let ids = sig.get(); @@ -850,6 +952,60 @@ fn AlternativeCard( .into_any(); } + if req.kind == "gesture" { + let container_ref: NodeRef<leptos::html::Div> = NodeRef::new(); + let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new(); + + // A div, not a label: a label would forward + // every click to the hidden input, and this + // field is drawn on, not clicked into. + #[cfg(feature = "hydrate")] + { + let relay = req.relay.clone().unwrap_or_default(); + // Unlike prosekit's fire-and-forget mount, + // the widget may own a live WebSocket - + // yes.js's StoredValue + on_cleanup pattern + // closes it on SPA navigation. + let widget: StoredValue<Option<gesture::GestureWidget>, LocalStorage> = + StoredValue::new_local(None); + Effect::new(move |_| { + let (Some(container), Some(hidden)) = + (container_ref.get(), hidden_ref.get()) + else { + return; + }; + if widget.with_value(|w| w.is_some()) { + return; + } + widget.set_value(Some(gesture::mount_gesture( + &container, &hidden, &relay, + ))); + }); + on_cleanup(move || { + widget.update_value(|opt| { + if let Some(w) = opt.take() { + w.stop(); + } + }); + }); + } + + return view! { + <div class="field"> + {label_text} + <input + id=field_id + type="hidden" + node_ref=hidden_ref + prop:value=move || sig.get() + on:input=move |ev| sig.set(event_target_value(&ev)) + /> + <div class="gesture-wrap" node_ref=container_ref></div> + </div> + } + .into_any(); + } + if req.kind == "prosekit" { let container_ref: NodeRef<leptos::html::Div> = NodeRef::new(); let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new(); @@ -1491,6 +1647,16 @@ pub async fn get_question(path: String) -> Result<Option<Question>, ServerFnErro Ok(state.questions.load().get(&path).cloned()) } +/// The content repo's branding (`site.yaml`) - title, wordmark, hero +/// kind. Defaults (= the historical uhhm look) when the repo declares +/// nothing. +#[server(endpoint = "get_site")] +pub async fn get_site() -> Result<SiteConfig, ServerFnError> { + use crate::server::AppState; + let state = expect_context::<AppState>(); + Ok(state.site.load().as_ref().clone()) +} + /// Every question the current visitor qualifies for, as (id, name) - /// the site nav's data. Context-dependent: an owner session sees the /// gated pages too, and `followup` pages only appear once the visitor diff --git a/src/bin/question_lint.rs b/src/bin/question_lint.rs index 528193d..db73f8b 100644 --- a/src/bin/question_lint.rs +++ b/src/bin/question_lint.rs @@ -32,12 +32,15 @@ async fn main() -> anyhow::Result<()> { } } - let (questions, aggregates_map) = if let Some(dir) = path { - (load_from_dir(&dir)?, load_aggregates_from_dir(&dir)?) + let (questions, aggregates_map, site) = if let Some(dir) = path { + (load_from_dir(&dir)?, load_aggregates_from_dir(&dir)?, load_site_from_dir(&dir)?) } else if let Some(repo_url) = repo { let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?; let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?; - (questions, aggregates_map) + // load_site_from_gitea already validates; a missing file is + // the default config, same as at portal boot. + let site = content::load_site_from_gitea(&repo_url, &branch).await?; + (questions, aggregates_map, site) } else { eprintln!( "usage: question-lint --repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>" @@ -45,6 +48,10 @@ async fn main() -> anyhow::Result<()> { std::process::exit(2); }; + if let Err(e) = site.validate() { + eprintln!("FAIL: {e}"); + std::process::exit(1); + } match content::validate_questions(&questions, &aggregates_map) { Ok(()) => { println!( @@ -61,6 +68,22 @@ async fn main() -> anyhow::Result<()> { } } +/// The offline counterpart to `content::load_site_from_gitea` - +/// `site.yaml` lives at the repo root like `aggregates.yaml`, and is +/// just as optional locally: missing means default branding. +fn load_site_from_dir(dir: &str) -> anyhow::Result<content::SiteConfig> { + let site_path = std::path::Path::new(dir) + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("site.yaml"); + if !site_path.exists() { + return Ok(content::SiteConfig::default()); + } + let raw = std::fs::read_to_string(&site_path) + .map_err(|e| anyhow::anyhow!("reading {}: {e}", site_path.display()))?; + serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display())) +} + /// The offline counterpart to `content::load_aggregates_from_gitea` - /// `aggregates.yaml` lives at the repo root, one level up from the /// pages directory `--path` names, so `dir`'s parent is where it's diff --git a/src/content.rs b/src/content.rs index f76a9ac..94bd88a 100644 --- a/src/content.rs +++ b/src/content.rs @@ -163,6 +163,11 @@ pub enum ResourceSource { Kv { bucket: String }, GiteaStarred { username: String }, GiteaOrgRepos { org: String }, + /// A repo's releases - fetched with `GITEA_API_TOKEN`, so it works + /// for private repos too (shape the link with jq: a private + /// release's html_url 404s for anonymous visitors, so map it to + /// null unless the repo is public). + GiteaReleases { owner: String, repo: String }, /// Any other HTTPS JSON endpoint. Scheme-restricted and checked /// against loopback/private/link-local addresses at fetch time /// (`resource::fetch_url_resource`) - a server-side fetch of a @@ -178,6 +183,7 @@ impl ResourceSpec { ResourceSource::Kv { bucket } => Some(bucket), ResourceSource::GiteaStarred { .. } | ResourceSource::GiteaOrgRepos { .. } + | ResourceSource::GiteaReleases { .. } | ResourceSource::Url { .. } => None, } } @@ -231,6 +237,13 @@ pub struct Requirement { /// the selected file's current content. #[serde(default)] pub bind: Option<Bind>, + /// `type: gesture` only - ws(s):// URL of a redoal-relay instance + /// the drawing widget connects to for live echoes of similar + /// strokes. Absent means the widget works offline: the drawn path + /// still submits, it just never gets a network-computed key or + /// echoes. + #[serde(default)] + pub relay: Option<String>, } /// A field's live data source, parameterized by a sibling field's @@ -265,6 +278,104 @@ impl Requirement { } } +/// Site-wide branding declared by the content repo - `site.yaml` at +/// the repo root, sibling of `aggregates.yaml`. An absent file means +/// all defaults, which is exactly the historical uhhm look: existing +/// deployments change nothing without a content edit. Everything the +/// browser needs, so it travels through a server fn (`get_site`). +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct SiteConfig { + /// Browser-tab title and wordmark alt text. `None` falls back to + /// the compile-time `SITE_NAME`. + #[serde(default)] + pub title: Option<String>, + /// Wordmark image URL (absolute or a path this instance serves). + /// `None` falls back to `/wordmark.svg`. + #[serde(default)] + pub wordmark: Option<String>, + #[serde(default)] + pub hero: HeroConfig, +} + +/// What the landing page's hero is: the YES canvas piece (`yes`, the +/// default), a redoal gesture-drawing canvas (`gesture`), or copy +/// only (`plain`). +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct HeroConfig { + #[serde(default = "default_hero_kind")] + pub kind: String, + /// `kind: gesture` only - ws(s):// URL of a redoal-relay for + /// ambient echoes. Absent means the hero draws offline. + #[serde(default)] + pub relay: Option<String>, +} + +impl Default for HeroConfig { + fn default() -> Self { + Self { kind: default_hero_kind(), relay: None } + } +} + +fn default_hero_kind() -> String { + "yes".to_string() +} + +#[cfg(feature = "ssr")] +impl SiteConfig { + /// Shared by `load_site_from_gitea` and the `question-lint` binary + /// so a typo'd hero kind is a caught rejection, not a silently + /// plain hero. + pub fn validate(&self) -> anyhow::Result<()> { + if !matches!(self.hero.kind.as_str(), "yes" | "gesture" | "plain") { + anyhow::bail!( + "site.yaml: hero.kind {:?} is not one of yes | gesture | plain", + self.hero.kind + ); + } + if let Some(relay) = &self.hero.relay { + if self.hero.kind != "gesture" { + anyhow::bail!("site.yaml: hero.relay only makes sense with hero.kind: gesture"); + } + validate_relay_url(relay).map_err(|e| anyhow::anyhow!("site.yaml: {e}"))?; + } + Ok(()) + } +} + +/// A relay must be a ws:// or wss:// URL - shared between site.yaml's +/// hero and `Requirement.relay` validation. +#[cfg(feature = "ssr")] +pub fn validate_relay_url(relay: &str) -> anyhow::Result<()> { + let parsed = url::Url::parse(relay) + .map_err(|e| anyhow::anyhow!("relay {relay:?} is not a valid URL: {e}"))?; + if !matches!(parsed.scheme(), "ws" | "wss") { + anyhow::bail!("relay {relay:?} must use the ws:// or wss:// scheme"); + } + Ok(()) +} + +/// Fetches and parses `site.yaml` from the content repo root. A fetch +/// failure (typically 404 - the file is optional) yields the default +/// config; a file that exists but doesn't parse or validate is a real +/// error, surfaced at boot rather than papered over. +#[cfg(feature = "ssr")] +pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result<SiteConfig> { + let (owner, repo) = parse_owner_repo(repo_url)?; + let api_base = gitea_api_base(repo_url)?; + let client = openidconnect::reqwest::Client::new(); + let raw = match fetch_gitea_file(&client, &api_base, &owner, &repo, branch, "site.yaml").await { + Ok(raw) => raw, + Err(e) => { + tracing::info!("no site.yaml in content repo ({e}), using default branding"); + return Ok(SiteConfig::default()); + } + }; + let site: SiteConfig = + serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?; + site.validate()?; + Ok(site) +} + /// Extracts `scheme://host` from a repo's normal browser URL - the /// Gitea API base every helper in this module builds requests against. #[cfg(feature = "ssr")] @@ -472,6 +583,26 @@ pub fn validate_questions( question.id, alternative.name, feature.name, requirement.name ); } + if let Some(relay) = &requirement.relay { + if requirement.kind != "gesture" { + anyhow::bail!( + "question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture", + question.id, alternative.name, feature.name, requirement.name, requirement.kind + ); + } + validate_relay_url(relay).map_err(|e| anyhow::anyhow!( + "question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}", + question.id, alternative.name, feature.name, requirement.name + ))?; + } + if requirement.kind == "gesture" + && (requirement.resource.is_some() || requirement.bind.is_some()) + { + anyhow::bail!( + "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: gesture - it draws its value, it can't also load one from a resource or bind", + question.id, alternative.name, feature.name, requirement.name + ); + } if let Some(bind) = &requirement.bind { if bind.field == requirement.name || !sibling_names.contains(bind.field.as_str()) @@ -543,6 +674,7 @@ pub async fn watch_for_reload( aggregates: std::sync::Arc< arc_swap::ArcSwap<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>, >, + site: std::sync::Arc<arc_swap::ArcSwap<SiteConfig>>, ) { let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await { Ok(sub) => sub, @@ -560,6 +692,17 @@ pub async fn watch_for_reload( continue; } }; + // Branding swaps with the same all-or-nothing rule: a + // present-but-broken site.yaml keeps last-good everything + // (load_site distinguishes "absent" - a normal default - from + // "present but invalid"). + let loaded_site = match load_site_from_gitea(&repo_url, &branch).await { + Ok(loaded) => loaded, + Err(e) => { + tracing::error!(error = %e, "site.yaml reload failed, keeping last-good content"); + continue; + } + }; match load_questions_from_gitea(&repo_url, &branch, &subdir).await { Ok(loaded) => { if let Err(e) = validate_questions(&loaded, &loaded_aggregates) { @@ -569,6 +712,7 @@ pub async fn watch_for_reload( let count = loaded.len(); questions.store(std::sync::Arc::new(loaded)); aggregates.store(std::sync::Arc::new(loaded_aggregates)); + site.store(std::sync::Arc::new(loaded_site)); tracing::info!(count, "reloaded content"); } Err(e) => { @@ -849,4 +993,80 @@ alternatives: let err = validate_questions(&question_with_bind("body"), &Default::default()).unwrap_err(); assert!(err.to_string().contains("not another requirement")); } + + fn question_with_requirement(req_yaml: &str) -> std::collections::HashMap<String, Question> { + let question: Question = serde_yaml::from_str(&format!( + "id: /g\nname: G\nalternatives:\n - name: A\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n" + )) + .unwrap(); + std::collections::HashMap::from([(question.id.clone(), question)]) + } + + #[test] + fn gesture_with_wss_relay_passes() { + let questions = question_with_requirement( + " - { name: curve, type: gesture, relay: \"wss://relay.redoal.com\", optional: true }", + ); + assert!(validate_questions(&questions, &Default::default()).is_ok()); + } + + #[test] + fn gesture_without_relay_passes_offline() { + let questions = + question_with_requirement(" - { name: curve, type: gesture, optional: true }"); + assert!(validate_questions(&questions, &Default::default()).is_ok()); + } + + #[test] + fn relay_on_non_gesture_kind_is_rejected() { + let questions = question_with_requirement( + " - { name: email, type: email, relay: \"wss://relay.redoal.com\" }", + ); + let err = validate_questions(&questions, &Default::default()).unwrap_err(); + assert!(err.to_string().contains("relay only makes sense on type: gesture")); + } + + #[test] + fn https_relay_is_rejected() { + let questions = question_with_requirement( + " - { name: curve, type: gesture, relay: \"https://relay.redoal.com\" }", + ); + let err = validate_questions(&questions, &Default::default()).unwrap_err(); + assert!(err.to_string().contains("ws:// or wss://")); + } + + #[test] + fn site_config_defaults_to_the_yes_hero() { + let site = SiteConfig::default(); + assert_eq!(site.hero.kind, "yes"); + assert!(site.validate().is_ok()); + // And an empty file parses to the same thing. + let parsed: SiteConfig = serde_yaml::from_str("{}").unwrap(); + assert_eq!(parsed, site); + } + + #[test] + fn site_config_rejects_unknown_hero_kind() { + let site: SiteConfig = serde_yaml::from_str("hero: { kind: fireworks }").unwrap(); + let err = site.validate().unwrap_err(); + assert!(err.to_string().contains("fireworks")); + } + + #[test] + fn site_config_rejects_relay_without_gesture_hero() { + let site: SiteConfig = + serde_yaml::from_str("hero: { kind: yes, relay: \"wss://relay.redoal.com\" }").unwrap(); + assert!(site.validate().is_err()); + } + + #[test] + fn redoal_site_yaml_shape_parses() { + let site: SiteConfig = serde_yaml::from_str( + "title: redoal\nwordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg\nhero:\n kind: gesture\n relay: wss://relay.redoal.com\n", + ) + .unwrap(); + assert!(site.validate().is_ok()); + assert_eq!(site.title.as_deref(), Some("redoal")); + assert_eq!(site.hero.kind, "gesture"); + } } diff --git a/src/main.rs b/src/main.rs index d212812..f452364 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,9 +32,11 @@ async fn main() -> anyhow::Result<()> { let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?; let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?; content::validate_questions(&questions, &aggregates)?; + let site = content::load_site_from_gitea(&content_repo, &content_branch).await?; tracing::info!(count = questions.len(), repo = %content_repo, branch = %content_branch, "loaded content"); let questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions)); let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates)); + let site = Arc::new(arc_swap::ArcSwap::from_pointee(site)); let nats_url = std::env::var("NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string()); @@ -69,6 +71,7 @@ async fn main() -> anyhow::Result<()> { "questions".to_string(), questions.clone(), aggregates.clone(), + site.clone(), )); let state = AppState { @@ -77,6 +80,7 @@ async fn main() -> anyhow::Result<()> { jetstream, questions, aggregates, + site, gitea_base, oidc: oidc_state, garage, diff --git a/src/resource.rs b/src/resource.rs index b2d8ebb..2261cfc 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -220,6 +220,10 @@ async fn fetch_resource_value( ResourceSource::GiteaOrgRepos { org } => { fetch_gitea_json(state, &format!("/api/v1/orgs/{org}/repos"), params).await? } + ResourceSource::GiteaReleases { owner, repo } => { + fetch_gitea_json(state, &format!("/api/v1/repos/{owner}/{repo}/releases"), params) + .await? + } ResourceSource::Url { url } => fetch_url_json(url, params).await?, }; @@ -500,6 +504,45 @@ mod tests { assert!(items[0].get("private").is_none()); } + /// Gitea-1.27-shaped release fixtures through the filter + /// redoal/questions' "Our Composition" feature declares. While the + /// repo is private the filter pins `url: null` - a private + /// release's html_url 404s for anonymous visitors, so the card + /// must render an unlinked heading instead. + #[test] + fn jq_shapes_gitea_release_list_for_redoal() { + let input = serde_json::json!([ + { + "name": "varde bring-up", + "tag_name": "v0.3.0", + "body": "Embedded varde-core on iOS.", + "published_at": "2026-08-16T09:00:00Z", + "html_url": "https://project.uhhm.no/redoal/redoal/releases/tag/v0.3.0", + "draft": false, + "prerelease": true + }, + { + "name": "first echo", + "tag_name": "v0.1.0", + "body": "Gesture keys round-trip.", + "published_at": "2026-05-01T09:00:00Z", + "html_url": "https://project.uhhm.no/redoal/redoal/releases/tag/v0.1.0", + "draft": false, + "prerelease": false + } + ]); + let filter = ".[] | {name: .name, description: .body, tag: .tag_name, published: .published_at, url: null}"; + + let shaped = apply_jq(filter, &input).expect("filter runs"); + let items = shaped.as_array().expect("array output"); + assert_eq!(items.len(), 2); + assert_eq!(items[0]["name"], "varde bring-up"); + assert_eq!(items[0]["tag"], "v0.3.0"); + assert_eq!(items[0]["description"], "Embedded varde-core on iOS."); + assert_eq!(items[0]["url"], serde_json::Value::Null, "no public link while private"); + assert!(items[0].get("html_url").is_none()); + } + #[test] fn is_public_ip_rejects_loopback_and_private() { let loopback: std::net::IpAddr = "127.0.0.1".parse().unwrap(); diff --git a/src/server/mod.rs b/src/server/mod.rs index 7b357f9..6a6b54a 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -28,6 +28,11 @@ pub struct AppState { /// with no entry here isn't event-sourced - plain KV mutate-in-place /// still works (see `answers.rs`). pub aggregates: Arc<ArcSwap<HashMap<String, crate::aggregates::AggregateSchema>>>, + /// Site branding from the content repo's optional `site.yaml` + /// (title, wordmark, hero kind) - hot-swapped with `questions`/ + /// `aggregates` on the same reload. Default = the historical uhhm + /// look. + pub site: Arc<ArcSwap<crate::content::SiteConfig>>, /// `scheme://host` of the Gitea instance content is loaded from /// (see `content::gitea_api_base`) - kept alongside `questions` /// rather than re-derived per call, since `resolve_gitea_repo` needs diff --git a/style/main.css b/style/main.css index b3df2ae..9c75f68 100644 --- a/style/main.css +++ b/style/main.css @@ -262,6 +262,31 @@ main.not-found { text-shadow: 0 0.12em 1.4em var(--hero-glow); } +/* The gesture hero (site.yaml hero.kind: gesture - redoal.com's + landing): a tall drawing surface where the visitor's stroke and + ambient echoes from the relay share the stage. Not sticky like the + YES piece - drawing and scrolling fight over the same finger. */ +.hero-gesture .hero-draw { + position: relative; + width: 100%; + max-width: 46rem; +} + +.hero-gesture .gesture-canvas { + aspect-ratio: auto; + /* Plain-vh fallback first, same reasoning as .hero-yes's height. */ + height: 55vh; + height: 55svh; + border: none; + background: transparent; +} + +.hero-gesture .gesture-status { + top: auto; + bottom: 0.55rem; + right: 0.55rem; +} + /* alternatives */ /* position + z-index on these three: everything that scrolls over the @@ -529,6 +554,68 @@ textarea:focus { overflow: hidden; } +/* The gesture drawing field (gesture.js). Stroke/ghost/echo colors + live in the JS module's own palette table - canvas can't read these + custom properties - so palette changes go there AND here. */ +.gesture-wrap { + position: relative; +} + +.gesture-canvas { + display: block; + width: 100%; + aspect-ratio: 3 / 2; + background: var(--paper); + border: 0.06rem solid var(--line); + border-radius: 0.6rem; + cursor: crosshair; + /* Load-bearing on touch: without it the browser pans instead of + letting the stroke happen. */ + touch-action: none; +} + +.gesture-echoes { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + min-height: 3rem; +} + +.gesture-echoes:empty { + display: none; +} + +.gesture-echo { + width: 3rem; + height: 3rem; + background: var(--paper); + border: 0.06rem solid var(--line); + border-radius: 0.4rem; + opacity: 0; + transition: opacity 0.5s ease; +} + +.gesture-echo.shown { + opacity: var(--echo-strength, 1); +} + +.gesture-status { + position: absolute; + top: 0.55rem; + right: 0.55rem; + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: var(--ink-dim); + opacity: 0.35; + transition: opacity 0.3s ease, background 0.3s ease; +} + +.gesture-status.live { + background: var(--accent); + opacity: 0.9; +} + .prosekit-toolbar { display: flex; gap: 0.2rem;