Gesture input type, redoal-relay client, gitea_releases, content-driven branding

Four coupled additions that let one portal build serve a second face
(redoal.com) next to uhhm.no:

- type: gesture requirement - gesture.js draws a single stroke on a
  DPR-aware canvas (pointer events, touch-action: none), mirrors
  {points, key} into the paired hidden input prosekit-style, and -
  when content declares relay: wss://... - speaks the redoal-relay
  protocol: announce on stroke end, ghost the ack's decoded key path,
  show echoes of similar strokes as thumbnails. Offline/broken relay
  degrades to a plain drawing input; the widget handle's stop()
  closes the socket on SPA navigation (yes.js lifecycle, not
  prosekit's fire-and-forget). Submit re-parses gesture values so the
  bucket stores a real object, not double-encoded JSON.
- gitea_releases resource source - token-authenticated
  /repos/{owner}/{repo}/releases, for advertising a private repo's
  releases (content pins url: null - private html_urls 404 publicly).
- site.yaml branding - optional, at the content repo root: title,
  wordmark, hero {kind: yes|gesture|plain, relay}. Absent file means
  the historical uhhm look, so uhhm changes nothing without a content
  edit. Hot-swapped with questions/aggregates on content reload;
  question_lint validates it in both --path and --repo modes.
- deploy.yml ships the same build twice: uhhm-portal (3010) as
  before, redoal-portal (3020, CONTENT_REPO=redoal/questions,
  redoal.com vhost). Needs host prep + REDOAL_OAUTH2_* repo
  secrets/vars before the new steps succeed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-23 13:03:29 +02:00
co-authored by Claude Fable 5
parent 6cd8cc6ff5
commit 0221253cba
9 changed files with 951 additions and 17 deletions
+54
View File
@@ -131,3 +131,57 @@ jobs:
} }
EOF EOF
docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile 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 <<EOF
NATS_URL=${{ secrets.PORTAL_NATS_URL }}
KANIDM_URL=${{ vars.PORTAL_KANIDM_URL }}
OAUTH2_CLIENT_ID=${{ vars.REDOAL_OAUTH2_CLIENT_ID }}
OAUTH2_CLIENT_SECRET=${{ secrets.REDOAL_OAUTH2_CLIENT_SECRET }}
PUBLIC_URL=https://redoal.com
COOKIE_SECURE=true
CONTENT_REPO=https://project.uhhm.no/redoal/questions
CONTENT_BRANCH=main
LEPTOS_SITE_ADDR=0.0.0.0:3020
LEPTOS_SITE_ROOT=site
# Same read-only token as uhhm's instance - here it also
# backs the gitea_releases source for the private
# redoal/redoal repo, so the owning user needs read access
# there.
GITEA_API_TOKEN=${{ secrets.PORTAL_GITEA_API_TOKEN }}
EOF
- name: Restart redoal service
run: systemctl restart app@redoal-portal.service
- name: Update redoal Caddy routing
run: |
cat > /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
+332
View File
@@ -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 <input> 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);
}
+180 -14
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem}; use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
use crate::auth::{current_user, User}; 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}; use crate::resource::{get_requirement_binding, get_requirement_options, get_resource};
/// The visible site name/wordmark - "portal" is just this codebase's /// 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 question = Resource::new(move || path.get(), get_question);
let user = Resource::new(|| (), |_| current_user()); let user = Resource::new(|| (), |_| current_user());
let site = Resource::new(|| (), |_| get_site());
view! { view! {
<Suspense fallback=|| { <Suspense fallback=|| {
@@ -99,16 +100,25 @@ fn QuestionPage() -> impl IntoView {
// has already resolved. // has already resolved.
let user_res = user.get(); let user_res = user.get();
let question_res = question.get(); let question_res = question.get();
let site_cfg = site.get().and_then(|r| r.ok()).unwrap_or_default();
question_res question_res
.map(|res| match res { .map(|res| match res {
Ok(Some(q)) => { Ok(Some(q)) => {
let current = user_res.and_then(|r| r.ok()).flatten(); let current = user_res.and_then(|r| r.ok()).flatten();
view! { view! {
// A second <Title> outranks App's
// SITE_NAME fallback only when content
// actually declares one.
{site_cfg
.title
.clone()
.map(|t| view! { <Title text=t/> })}
<QuestionView <QuestionView
question=q question=q
parent_hash=parent_hash.get() parent_hash=parent_hash.get()
query_email=query_email.get() query_email=query_email.get()
user=current user=current
site=site_cfg
/> />
} }
.into_any() .into_any()
@@ -126,6 +136,7 @@ fn QuestionView(
parent_hash: Option<String>, parent_hash: Option<String>,
query_email: Option<String>, query_email: Option<String>,
user: Option<User>, user: Option<User>,
site: SiteConfig,
) -> impl IntoView { ) -> impl IntoView {
let question_id = question.id.clone(); let question_id = question.id.clone();
@@ -138,7 +149,8 @@ fn QuestionView(
<Hero <Hero
title=question.name.clone() title=question.name.clone()
description=question.description.clone() description=question.description.clone()
show_yes=question_id == "/" landing=question_id == "/"
site=site.clone()
/> />
<div class="alternatives"> <div class="alternatives">
<section class="alt-card gate-card"> <section class="alt-card gate-card">
@@ -175,7 +187,8 @@ fn QuestionView(
<Hero <Hero
title=question.name.clone() title=question.name.clone()
description=question.description.clone() description=question.description.clone()
show_yes=question_id == "/" landing=question_id == "/"
site=site.clone()
/> />
<div class="alternatives"> <div class="alternatives">
<For <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] #[component]
fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView { fn Hero(title: String, description: String, landing: bool, site: SiteConfig) -> impl IntoView {
// Only the landing page gets the full interactive piece - it's the // Only the landing page gets a full interactive piece - it's the
// one page this is actually "the" hero for; other pages (thank-you // one page a hero is actually "the" hero for; other pages
// pages, /review) get the plain dark header below. Starts the // (thank-you pages, /review) get the plain dark header below.
// animation once the canvas elements exist, and explicitly stops // WHICH piece is the content repo's call (`site.yaml`'s
// the requestAnimationFrame loop on unmount - the original // hero.kind): the YES canvas (default - uhhm's look), a redoal
// page-owning script never needed this since navigating away meant // gesture canvas, or nothing.
// a full document unload, which doesn't happen in an SPA. 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 raster_ref: NodeRef<leptos::html::Canvas> = NodeRef::new();
let gesture_ref: NodeRef<leptos::html::Div> = NodeRef::new();
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
if show_yes { if show_yes {
// `on_cleanup` requires Send + Sync (even single-threaded, wasm), // `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! { view! {
<header class="hero" class:hero-yes=show_yes> <header class="hero" class:hero-yes=show_yes class:hero-gesture=show_gesture>
{show_yes {show_yes
.then(|| { .then(|| {
view! { view! {
@@ -402,9 +483,13 @@ fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView {
</div> </div>
} }
})} })}
{show_gesture
.then(|| {
view! { <div class="hero-draw" node_ref=gesture_ref></div> }
})}
<div class="hero-copy"> <div class="hero-copy">
<a class="wordmark" href="/"> <a class="wordmark" href="/">
<img src="/wordmark.svg" alt=SITE_NAME/> <img src=wordmark alt=site_title/>
</a> </a>
<h1>{title}</h1> <h1>{title}</h1>
<p>{description}</p> <p>{description}</p>
@@ -536,6 +621,10 @@ fn AlternativeCard(
let mut select_field_map: std::collections::HashMap<String, RwSignal<Vec<String>>> = let mut select_field_map: std::collections::HashMap<String, RwSignal<Vec<String>>> =
std::collections::HashMap::new(); std::collections::HashMap::new();
let mut select_multi: std::collections::HashMap<String, bool> = 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 feature in &alternative.features {
for req in &feature.requirements { for req in &feature.requirements {
if req.kind == "file" { if req.kind == "file" {
@@ -546,6 +635,9 @@ fn AlternativeCard(
.or_insert_with(|| RwSignal::new(Vec::new())); .or_insert_with(|| RwSignal::new(Vec::new()));
select_multi.insert(req.name.clone(), req.multiple); select_multi.insert(req.name.clone(), req.multiple);
} else { } else {
if req.kind == "gesture" {
gesture_fields.insert(req.name.clone());
}
field_map field_map
.entry(req.name.clone()) .entry(req.name.clone())
.or_insert_with(|| RwSignal::new(String::new())); .or_insert_with(|| RwSignal::new(String::new()));
@@ -571,6 +663,7 @@ fn AlternativeCard(
let file_refs_for_submit = file_refs.clone(); let file_refs_for_submit = file_refs.clone();
let select_field_map_for_submit = select_field_map.clone(); let select_field_map_for_submit = select_field_map.clone();
let select_multi_for_submit = select_multi.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 question_id_for_transition = question_id.clone();
let alt_name_for_transition = alternative.name.clone(); let alt_name_for_transition = alternative.name.clone();
let on_submit = move |ev: leptos::ev::SubmitEvent| { 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 file_refs_for_submit = file_refs_for_submit.clone();
let select_field_map_for_submit = select_field_map_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 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 question_id_for_submit = question_id_for_submit.clone();
let alt_name_for_submit = alt_name_for_submit.clone(); let alt_name_for_submit = alt_name_for_submit.clone();
let parent_hash_for_submit = parent_hash_for_submit.clone(); let parent_hash_for_submit = parent_hash_for_submit.clone();
leptos::task::spawn_local(async move { leptos::task::spawn_local(async move {
let mut map = serde_json::Map::new(); let mut map = serde_json::Map::new();
for (name, sig) in field_map_for_submit.iter() { 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() { for (name, sig) in select_field_map_for_submit.iter() {
let ids = sig.get(); let ids = sig.get();
@@ -850,6 +952,60 @@ fn AlternativeCard(
.into_any(); .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" { if req.kind == "prosekit" {
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new(); let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let hidden_ref: NodeRef<leptos::html::Input> = 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()) 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) - /// Every question the current visitor qualifies for, as (id, name) -
/// the site nav's data. Context-dependent: an owner session sees the /// the site nav's data. Context-dependent: an owner session sees the
/// gated pages too, and `followup` pages only appear once the visitor /// gated pages too, and `followup` pages only appear once the visitor
+26 -3
View File
@@ -32,12 +32,15 @@ async fn main() -> anyhow::Result<()> {
} }
} }
let (questions, aggregates_map) = if let Some(dir) = path { let (questions, aggregates_map, site) = if let Some(dir) = path {
(load_from_dir(&dir)?, load_aggregates_from_dir(&dir)?) (load_from_dir(&dir)?, load_aggregates_from_dir(&dir)?, load_site_from_dir(&dir)?)
} else if let Some(repo_url) = repo { } else if let Some(repo_url) = repo {
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?; let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?;
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).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 { } else {
eprintln!( eprintln!(
"usage: question-lint --repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>" "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); std::process::exit(2);
}; };
if let Err(e) = site.validate() {
eprintln!("FAIL: {e}");
std::process::exit(1);
}
match content::validate_questions(&questions, &aggregates_map) { match content::validate_questions(&questions, &aggregates_map) {
Ok(()) => { Ok(()) => {
println!( 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` - /// The offline counterpart to `content::load_aggregates_from_gitea` -
/// `aggregates.yaml` lives at the repo root, one level up from the /// `aggregates.yaml` lives at the repo root, one level up from the
/// pages directory `--path` names, so `dir`'s parent is where it's /// pages directory `--path` names, so `dir`'s parent is where it's
+220
View File
@@ -163,6 +163,11 @@ pub enum ResourceSource {
Kv { bucket: String }, Kv { bucket: String },
GiteaStarred { username: String }, GiteaStarred { username: String },
GiteaOrgRepos { org: 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 /// Any other HTTPS JSON endpoint. Scheme-restricted and checked
/// against loopback/private/link-local addresses at fetch time /// against loopback/private/link-local addresses at fetch time
/// (`resource::fetch_url_resource`) - a server-side fetch of a /// (`resource::fetch_url_resource`) - a server-side fetch of a
@@ -178,6 +183,7 @@ impl ResourceSpec {
ResourceSource::Kv { bucket } => Some(bucket), ResourceSource::Kv { bucket } => Some(bucket),
ResourceSource::GiteaStarred { .. } ResourceSource::GiteaStarred { .. }
| ResourceSource::GiteaOrgRepos { .. } | ResourceSource::GiteaOrgRepos { .. }
| ResourceSource::GiteaReleases { .. }
| ResourceSource::Url { .. } => None, | ResourceSource::Url { .. } => None,
} }
} }
@@ -231,6 +237,13 @@ pub struct Requirement {
/// the selected file's current content. /// the selected file's current content.
#[serde(default)] #[serde(default)]
pub bind: Option<Bind>, 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 /// 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 /// Extracts `scheme://host` from a repo's normal browser URL - the
/// Gitea API base every helper in this module builds requests against. /// Gitea API base every helper in this module builds requests against.
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
@@ -472,6 +583,26 @@ pub fn validate_questions(
question.id, alternative.name, feature.name, requirement.name 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 let Some(bind) = &requirement.bind {
if bind.field == requirement.name if bind.field == requirement.name
|| !sibling_names.contains(bind.field.as_str()) || !sibling_names.contains(bind.field.as_str())
@@ -543,6 +674,7 @@ pub async fn watch_for_reload(
aggregates: std::sync::Arc< aggregates: std::sync::Arc<
arc_swap::ArcSwap<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>, 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 { let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await {
Ok(sub) => sub, Ok(sub) => sub,
@@ -560,6 +692,17 @@ pub async fn watch_for_reload(
continue; 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 { match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
Ok(loaded) => { Ok(loaded) => {
if let Err(e) = validate_questions(&loaded, &loaded_aggregates) { if let Err(e) = validate_questions(&loaded, &loaded_aggregates) {
@@ -569,6 +712,7 @@ pub async fn watch_for_reload(
let count = loaded.len(); let count = loaded.len();
questions.store(std::sync::Arc::new(loaded)); questions.store(std::sync::Arc::new(loaded));
aggregates.store(std::sync::Arc::new(loaded_aggregates)); aggregates.store(std::sync::Arc::new(loaded_aggregates));
site.store(std::sync::Arc::new(loaded_site));
tracing::info!(count, "reloaded content"); tracing::info!(count, "reloaded content");
} }
Err(e) => { Err(e) => {
@@ -849,4 +993,80 @@ alternatives:
let err = validate_questions(&question_with_bind("body"), &Default::default()).unwrap_err(); let err = validate_questions(&question_with_bind("body"), &Default::default()).unwrap_err();
assert!(err.to_string().contains("not another requirement")); 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");
}
} }
+4
View File
@@ -32,9 +32,11 @@ async fn main() -> anyhow::Result<()> {
let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?; 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?; let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
content::validate_questions(&questions, &aggregates)?; 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"); tracing::info!(count = questions.len(), repo = %content_repo, branch = %content_branch, "loaded content");
let questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions)); let questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions));
let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates)); let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates));
let site = Arc::new(arc_swap::ArcSwap::from_pointee(site));
let nats_url = let nats_url =
std::env::var("NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string()); 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".to_string(),
questions.clone(), questions.clone(),
aggregates.clone(), aggregates.clone(),
site.clone(),
)); ));
let state = AppState { let state = AppState {
@@ -77,6 +80,7 @@ async fn main() -> anyhow::Result<()> {
jetstream, jetstream,
questions, questions,
aggregates, aggregates,
site,
gitea_base, gitea_base,
oidc: oidc_state, oidc: oidc_state,
garage, garage,
+43
View File
@@ -220,6 +220,10 @@ async fn fetch_resource_value(
ResourceSource::GiteaOrgRepos { org } => { ResourceSource::GiteaOrgRepos { org } => {
fetch_gitea_json(state, &format!("/api/v1/orgs/{org}/repos"), params).await? 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?, ResourceSource::Url { url } => fetch_url_json(url, params).await?,
}; };
@@ -500,6 +504,45 @@ mod tests {
assert!(items[0].get("private").is_none()); 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] #[test]
fn is_public_ip_rejects_loopback_and_private() { fn is_public_ip_rejects_loopback_and_private() {
let loopback: std::net::IpAddr = "127.0.0.1".parse().unwrap(); let loopback: std::net::IpAddr = "127.0.0.1".parse().unwrap();
+5
View File
@@ -28,6 +28,11 @@ pub struct AppState {
/// with no entry here isn't event-sourced - plain KV mutate-in-place /// with no entry here isn't event-sourced - plain KV mutate-in-place
/// still works (see `answers.rs`). /// still works (see `answers.rs`).
pub aggregates: Arc<ArcSwap<HashMap<String, crate::aggregates::AggregateSchema>>>, 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 /// `scheme://host` of the Gitea instance content is loaded from
/// (see `content::gitea_api_base`) - kept alongside `questions` /// (see `content::gitea_api_base`) - kept alongside `questions`
/// rather than re-derived per call, since `resolve_gitea_repo` needs /// rather than re-derived per call, since `resolve_gitea_repo` needs
+87
View File
@@ -262,6 +262,31 @@ main.not-found {
text-shadow: 0 0.12em 1.4em var(--hero-glow); 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 */ /* alternatives */
/* position + z-index on these three: everything that scrolls over the /* position + z-index on these three: everything that scrolls over the
@@ -529,6 +554,68 @@ textarea:focus {
overflow: hidden; 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 { .prosekit-toolbar {
display: flex; display: flex;
gap: 0.2rem; gap: 0.2rem;