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
+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 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,