Compare commits

..
21 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug 7429f1b9e1 Release 0.2.1
Test / test (push) Successful in 23s
Publish release / publish (push) Successful in 1m23s
2026-08-24 22:40:30 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 a0305282ef Revalidating cache on app assets; site title survives SPA navigation
Test / test (push) Successful in 24s
pkg files keep stable names across releases, so an uncontrolled
browser cache could pair last release's wasm with the new server's
server-fn wire format - every page then renders its error branch
(redoal.com's 'Nothing here' after v0.2.0). Cache-Control: no-cache
on /pkg and /yes.js makes clients revalidate (a 304 per load) instead
of guessing. The content-declared site title also moves out of the
question Suspense: remounting with each navigation lost the
leptos_meta race to the compile-time fallback, flipping redoal.com's
tab to 'uhhm' on the first client-side nav.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:40:29 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c67f6f1a59 Docs: routing bullet + design doc marked implemented
Test / test (push) Successful in 23s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:33:37 +02:00
Bendik Aagaard Lynghaug 4e69f26fcc Release 0.2.0
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m8s
2026-08-24 22:30:35 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 452ea88fbf Filesystem routes, sections, dynamic segments; instant YES hero
Test / test (push) Successful in 23s
The questions/ tree is the router now: ids derive from file paths
(index.yaml names its directory; explicit id still wins for legacy
content), actions and requires_chain accept relative refs, nested
non-index files infer followup, and _section.yaml applies qualifies/
requires_chain/responsible to everything under its directory. Dynamic
[name].yaml pages serve any /dir/<value> with the segment substituted
into {name} resource-key placeholders; submissions index their chain
node in a portal_chains KV so requires_chain pages can verify a
visitor's ?chain= lineage actually ends at the required question.
Loading uses one recursive git-trees call; question_lint walks
subdirectories the same way. Implements docs/design/filesystem-routes.md.

Also: the YES hero now starts at HTML parse time via an inline module
script (yes.js moved to public/ for a stable /yes.js the wasm binding
raw_module-imports too - snippet paths are per-build-hashed), with
hydration adopting the running instance; and both gesture containers
reserve their box in CSS so mounting doesn't shift content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:30:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b737e0a7e7 Design doc: filesystem routes, sections, dynamic segments
Test / test (push) Successful in 24s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:58:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b354db2e76 README: frame portal as a generic multi-site question engine
Test / test (push) Successful in 24s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:53:17 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 74965376bd Docs: release/rollout flow after the instance-ownership refactor
Test / test (push) Successful in 23s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:49:31 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 8858336ecc Semver releases cut with cargo-release; publish on v* tags only
Test / test (push) Successful in 23s
Publish release / publish (push) Successful in 1m8s
cargo release <level> bumps, tags v<semver>, and pushes; publish.yml
reacts to the tag and attaches the artifact to that release. Plain
main pushes now run tests (test.yml) instead of publishing build-<sha>
artifacts on every push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:44:31 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 2b593ba53f Deploy becomes publish: portal ships as a versioned release artifact
Publish release / publish (push) Successful in 1m7s
The portal repo no longer deploys instances. CI builds once, packages
portal + question_lint + site/ into a tarball, and publishes it as a
Gitea release tagged build-<shortsha> using the run's ephemeral token.
Content repos (uhhm/questions, redoal/questions) now own their instance
deploys - env, unit restart, Caddy route - pinned to a release tag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:21:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 e0094f2e97 Trigger deploy: redoal OAuth2 credentials now configured
Deploy / deploy (push) Successful in 1m8s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 20:39:16 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 0def91f8ab Echo thumbnails fade in via timeout, not rAF
Deploy / deploy (push) Successful in 1m5s
The 'shown' class only needs a later tick for the opacity transition
to run - requestAnimationFrame never fires in headless engines
(caught in hwatu verification), and a timeout is equivalent in real
browsers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:16:53 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 0221253cba 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>
2026-08-23 13:03:29 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 6cd8cc6ff5 Showcase jq test tracks Gitea repo shape and website-first links
Deploy / deploy (push) Successful in 1m2s
Mirrors the index.yaml filter change in uhhm/questions: fixture now
carries Gitea 1.27's actual fields (stars_count, website, private)
and the test covers all three link outcomes - public repo without a
website links to the repo, private with a website links there, and
private without one gets url: null so the card renders unlinked
instead of 404ing for visitors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:27:49 +02:00
Bendik Aagaard Lynghaug 394133b687 increase translucency of laternative cards
Deploy / deploy (push) Successful in 1m4s
2026-08-17 18:50:09 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 1b7ffe0f43 Noise-driven YES, sticky hero with frosted cards, full light theme
Deploy / deploy (push) Successful in 1m0s
Three changes that belong together, all verified against a real
mounted page in headless WebKit:

- yes.js's two behavior dials (containment/wiggle) now drift on a
  smooth two-octave value-noise field at cloud pace (~25s per weather
  change) instead of following the mouse. Includes a real bug fix
  found in verification: the hash's final XOR yields a SIGNED 32-bit
  value in JS, so the "0..1" noise dipped to -0.36 without a
  reinterpreting >>> 0.

- The landing hero is position: sticky, so the piece keeps animating
  behind the whole page. Cards go translucent with backdrop blur and
  a soft shadow so the piece reads faintly through and around them
  (near-opaque fallback where backdrop-filter is unsupported). The
  hero copy fades out over the first half-screen of scroll - pinned,
  it ghosted through the cards. The bottom fade gradient is gone: its
  hard-cut reason disappeared with the canvas behind everything.

- Full prefers-color-scheme light theme: warm paper, near-black ink,
  accent deepened from dusty cyan to teal ink (#8ec2c0 washes out on
  white), wordmark inverted via filter. yes.js mirrors the palette
  itself (canvas can't read CSS vars): CMYK process-ink strokes dark
  enough to carry on paper, raster ghost repainted as multiply-blended
  gray on white, live re-theme on scheme flip with a trail clear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:53:55 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 ee91b092d1 Drop stray local tool temp file, ignore .claude/
Deploy / deploy (push) Successful in 1m1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:18:24 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c9c5caf3ff Stable server fn endpoints: open tabs survive deploys
Deploy / deploy (push) Successful in 1m3s
The 'previous question's resources until refresh' mystery: leptos's
auto-generated server fn routes embed a hash that changes across
builds. Every deploy therefore breaks every already-open tab - its
wasm keeps calling /api/get_question<oldhash>, the new server answers
400 'Could not find a server function at the route', and client-side
navigation quietly leaves the previous question's data on screen.
Refresh loads the new wasm with matching hashes, which is why it
always fixed it. Confirmed live: a pre-deploy client 400ed on
get_question2970801986613442004 while the freshly served wasm calls
get_question13103328088426240960, same source on both builds.

Explicit endpoint names decouple the URL from the build. This deploy
is the last breaking one; after it, old clients keep working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:18:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 5b3ffa62e8 Editor type scales for real: drop prosekit typography.css, fix selectors
Deploy / deploy (push) Successful in 1m16s
The earlier rem override never matched: editor.mount() turns the
.prosekit-editor div itself into the .ProseMirror root, so
'.prosekit-editor .ProseMirror p' selected nothing and prosekit
typography.css's fixed 16px kept winning. Verified by mounting the
editor standalone and reading the mount div's classes.

Rather than out-specificity a stylesheet we don't control, stop
loading it: prosekit's typography.css is optional px-based sugar. Our
own rem scale (now selected as direct children of .prosekit-editor)
plus UA em defaults for lists/blockquote/code is the whole editor
typography, all tracking the responsive root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 23:16:16 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 7b5d4dbf52 No translucent input chrome: solid ink-dim placeholders and embed meta
Deploy / deploy (push) Successful in 1m15s
One placeholder rule for native inputs and the prosekit editor alike;
kills ProseKit's 0.3-opacity placeholder and Firefox's UA dimming. The
embed description drops inline opacity for a fixed mid-gray that stays
dim on both the newsletter's light background and our dark paper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 23:05:43 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 117cd3216d Ban px from stylesheets: every length rem/em off the responsive root
Deploy / deploy (push) Successful in 1m15s
Only the :root font-size definition itself stays absolute - it is the
base everything derives from. Embed-card inline styles in the editor
follow the same rule; already-stored embeds keep their old px inline
styles until re-edited.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 22:46:14 +02:00
21 changed files with 2349 additions and 379 deletions
-133
View File
@@ -1,133 +0,0 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: bare
env:
# The bare runner's own systemd service intentionally has a minimal
# PATH/HOME (no rustup default toolchain in reach) - point it at the
# shared toolchain install directly rather than assuming an ambient
# dev shell environment.
CARGO_HOME: /var/local/cargo
RUSTUP_HOME: /var/local/rustup
PATH: /var/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/bin
# Shared with interactive dev builds (see ~/.config/fish/config.fish)
# so a crate compiled once, by either a manual build or CI, is
# cached for the other - real cache hits, not just a warm toolchain.
# SCCACHE_SERVER_PORT deliberately differs from the interactive
# dev shell's (4227): sccache's server is discovered by a fixed
# TCP port shared by every local user, so if both contexts used
# the same port, whichever one's server happened to be running
# would silently "win" and serve build requests it doesn't have
# filesystem permission for. Separate ports keep each context's
# own server answering its own requests; the cache directory
# (not the server process) is what's actually shared.
SCCACHE_DIR: /var/local/sccache
SCCACHE_SERVER_PORT: "4228"
CARGO_TARGET_DIR: /var/local/cargo-target
# cargo-leptos's own downloaded tools (wasm-bindgen, wasm-opt) cache
# under $XDG_CACHE_HOME - left at its default that resolves inside
# this unit's systemd StateDirectory, files it creates there end up
# owned by the kernel's overflow "nobody" uid instead of the
# runner's own dynamic uid (a StateDirectory quirk, not something
# in our control), so a later run can't execute what an earlier run
# downloaded. Redirecting it to our own known-good shared dir avoids
# that entirely.
XDG_CACHE_HOME: /var/local/leptos-cache
steps:
- uses: actions/checkout@v4
# SITE_NAME is read via option_env! (src/app.rs) - compile-time,
# not a runtime env var - so it has to be set here, not just in
# the "Write service env" step below.
- name: Build
run: SITE_NAME=${{ vars.PORTAL_SITE_NAME }} cargo leptos build --release
# cargo-leptos only builds the leptos bin-target ("portal") - the
# question-lint utility binary needs its own plain cargo build.
- name: Build question-lint
run: cargo build --release --bin question_lint --features ssr
- name: Ship release
run: |
set -euo pipefail
rel="/srv/app/uhhm-portal/releases/${{ github.sha }}"
mkdir -p "$rel"
cp "$CARGO_TARGET_DIR/release/portal" "$rel/uhhm-portal"
# questions' own CI execs this directly by path (same bare-metal
# runner/host as this job, no artifact download needed) instead
# of running its own hand-maintained yq/jq subset of these rules.
cp "$CARGO_TARGET_DIR/release/question_lint" "$rel/question_lint"
# site-root ("target/site" in Cargo.toml) is project-relative,
# not affected by CARGO_TARGET_DIR - only the plain `cargo build`
# outputs (release/, front/) move with that override.
cp -r target/site "$rel/site"
ln -sfn "$rel" /srv/app/uhhm-portal/current
# Sourced from this repo's own Settings -> Actions Variables/Secrets,
# not typed onto the host by hand - see the infrastructure repo's
# deploy-runner plan for the exact names/values to configure once.
- name: Write service env
run: |
cat > /etc/app/uhhm-portal.env <<EOF
NATS_URL=${{ secrets.PORTAL_NATS_URL }}
KANIDM_URL=${{ vars.PORTAL_KANIDM_URL }}
OAUTH2_CLIENT_ID=${{ vars.PORTAL_OAUTH2_CLIENT_ID }}
OAUTH2_CLIENT_SECRET=${{ secrets.PORTAL_OAUTH2_CLIENT_SECRET }}
PUBLIC_URL=${{ vars.PORTAL_PUBLIC_URL }}
COOKIE_SECURE=true
CONTENT_REPO=https://project.uhhm.no/uhhm/questions
CONTENT_BRANCH=main
SITE_NAME=${{ vars.PORTAL_SITE_NAME }}
LEPTOS_SITE_ADDR=0.0.0.0:3010
# Cargo.toml's site-root ("target/site") is a build-time path;
# the deploy layout copies the bundle to releases/<sha>/site
# (no target/ prefix) - override so the running binary looks
# in the right place for /pkg/*.
LEPTOS_SITE_ROOT=site
AUTOMATION_READ_TOKEN=${{ secrets.PORTAL_AUTOMATION_READ_TOKEN }}
# Read-only (read:user,read:repository,read:organization),
# used only by the GiteaStarred/GiteaOrgRepos resource sources
# (src/resource.rs) - the repo-contents/repo-info calls
# content loading already makes stay anonymous.
GITEA_API_TOKEN=${{ secrets.PORTAL_GITEA_API_TOKEN }}
EOF
# No sudo: the runner's own unit sets NoNewPrivileges=yes, which
# blocks setuid escalation outright (sudo can't work at all under
# it, regardless of sudoers config) - systemctl talks to PID1 over
# D-Bus instead, authorized by a polkit rule scoped to deploy-runner
# + this unit pattern (see /etc/polkit-1/rules.d/10-deploy-runner.rules
# on the host).
- name: Restart service
run: systemctl restart app@uhhm-portal.service
# No sudo here either - the runner is already in the `docker` group,
# so it can talk to the Docker socket directly.
# Apex and www are separate cookie scopes (no shared Domain
# attribute on the session cookie), but Kanidm's redirect_uri is
# fixed to PUBLIC_URL - a login started on the other host set its
# session cookie there, then landed on PUBLIC_URL's callback with
# an empty session ("no login in progress"). Redirecting www to
# the naked domain keeps every visit on one canonical host
# instead - PUBLIC_URL (repo variable) is set to https://{$DOMAIN}
# to match.
- name: Update Caddy routing
run: |
cat > /etc/caddy/services.d/uhhm-portal.caddy <<'EOF'
www.{$DOMAIN} {
redir https://{$DOMAIN}{uri} permanent
}
{$DOMAIN} {
reverse_proxy host.docker.internal:3010
log {
output file /var/log/caddy/www.log
}
}
EOF
docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
+101
View File
@@ -0,0 +1,101 @@
name: Publish release
# Portal does not deploy itself. Cutting a version is deliberate:
# `cargo release <level>` bumps Cargo.toml, commits, tags v<semver>,
# and pushes; this workflow reacts to the tag and publishes the
# artifact as a Gitea release. Each content repo (uhhm/questions,
# redoal/questions) pins PORTAL_RELEASE to one of these tags in its
# own deploy workflow - bumping the pin there is what rolls a version
# out to a site. Plain main pushes only run test.yml.
on:
push:
tags: ["v*"]
jobs:
publish:
runs-on: bare
env:
# The bare runner's own systemd service intentionally has a minimal
# PATH/HOME (no rustup default toolchain in reach) - point it at the
# shared toolchain install directly rather than assuming an ambient
# dev shell environment.
CARGO_HOME: /var/local/cargo
RUSTUP_HOME: /var/local/rustup
PATH: /var/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/bin
# Shared with interactive dev builds (see ~/.config/fish/config.fish)
# so a crate compiled once, by either a manual build or CI, is
# cached for the other - real cache hits, not just a warm toolchain.
# SCCACHE_SERVER_PORT deliberately differs from the interactive
# dev shell's (4227): sccache's server is discovered by a fixed
# TCP port shared by every local user, so if both contexts used
# the same port, whichever one's server happened to be running
# would silently "win" and serve build requests it doesn't have
# filesystem permission for. Separate ports keep each context's
# own server answering its own requests; the cache directory
# (not the server process) is what's actually shared.
SCCACHE_DIR: /var/local/sccache
SCCACHE_SERVER_PORT: "4228"
CARGO_TARGET_DIR: /var/local/cargo-target
# cargo-leptos's own downloaded tools (wasm-bindgen, wasm-opt) cache
# under $XDG_CACHE_HOME - left at its default that resolves inside
# this unit's systemd StateDirectory, files it creates there end up
# owned by the kernel's overflow "nobody" uid instead of the
# runner's own dynamic uid (a StateDirectory quirk, not something
# in our control), so a later run can't execute what an earlier run
# downloaded. Redirecting it to our own known-good shared dir avoids
# that entirely.
XDG_CACHE_HOME: /var/local/leptos-cache
steps:
- uses: actions/checkout@v4
# SITE_NAME is read via option_env! (src/app.rs) - compile-time,
# not a runtime env var. It is only the last-resort fallback name:
# each instance's site.yaml (content-driven branding) overrides it,
# so one artifact serves every site.
- name: Build
run: SITE_NAME=${{ vars.PORTAL_SITE_NAME }} cargo leptos build --release
# cargo-leptos only builds the leptos bin-target ("portal") - the
# question-lint utility binary needs its own plain cargo build.
- name: Build question-lint
run: cargo build --release --bin question_lint --features ssr
- name: Package
run: |
set -euo pipefail
tag="${{ github.ref_name }}"
stage=$(mktemp -d)
cp "$CARGO_TARGET_DIR/release/portal" "$stage/portal"
cp "$CARGO_TARGET_DIR/release/question_lint" "$stage/question_lint"
# site-root ("target/site" in Cargo.toml) is project-relative,
# not affected by CARGO_TARGET_DIR - only the plain `cargo build`
# outputs (release/, front/) move with that override.
cp -r target/site "$stage/site"
tar -C "$stage" -czf "portal-$tag.tar.gz" portal question_lint site
rm -rf "$stage"
# The run's own ephemeral token has write access to this repo -
# no long-lived PAT to manage. The tag already exists (cargo
# release pushed it), so the release attaches to it; a re-run
# finds the existing release instead of failing.
- name: Publish release
run: |
set -euo pipefail
tag="${{ github.ref_name }}"
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
auth="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
subject=$(git log -1 --format=%s)
body=$(printf '{"tag_name":"%s","name":"%s"}' \
"$tag" "$tag: $(echo "$subject" | sed 's/"/\\"/g')")
id=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "$body" "$api/releases" | jq .id) \
|| id=$(curl -sf -H "$auth" "$api/releases/tags/$tag" | jq .id)
# Replace the asset if a re-run already uploaded one.
for aid in $(curl -sf -H "$auth" "$api/releases/$id/assets" | jq '.[].id'); do
curl -sf -X DELETE -H "$auth" "$api/releases/$id/assets/$aid"
done
curl -sf -X POST -H "$auth" \
-F "attachment=@portal-$tag.tar.gz" \
"$api/releases/$id/assets?name=portal-$tag.tar.gz" > /dev/null
echo "published $tag"
+26
View File
@@ -0,0 +1,26 @@
name: Test
# Publishing only happens on v* tags (publish.yml), so this is what
# keeps plain main pushes honest between releases.
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: bare
env:
# Same shared-toolchain/cache story as publish.yml.
CARGO_HOME: /var/local/cargo
RUSTUP_HOME: /var/local/rustup
PATH: /var/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/bin
SCCACHE_DIR: /var/local/sccache
SCCACHE_SERVER_PORT: "4228"
CARGO_TARGET_DIR: /var/local/cargo-target
steps:
- uses: actions/checkout@v4
- name: Test
run: cargo test --features ssr
+1
View File
@@ -1 +1,2 @@
/target /target
.claude/
Generated
+1 -1
View File
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]] [[package]]
name = "portal" name = "portal"
version = "0.1.0" version = "0.2.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+11 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "portal" name = "portal"
version = "0.1.0" version = "0.2.1"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -19,7 +19,7 @@ axum = { version = "0.8", features = ["multipart"], optional = true }
aws-sdk-s3 = { version = "1", optional = true } aws-sdk-s3 = { version = "1", optional = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"], optional = true } tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"], optional = true }
tower = { version = "0.5", optional = true } tower = { version = "0.5", optional = true }
tower-http = { version = "0.6", features = ["fs"], optional = true } tower-http = { version = "0.6", features = ["fs", "set-header"], optional = true }
tower-sessions = { version = "0.14", optional = true } tower-sessions = { version = "0.14", optional = true }
async-nats = { version = "0.38", optional = true } async-nats = { version = "0.38", optional = true }
arc-swap = { version = "1", optional = true } arc-swap = { version = "1", optional = true }
@@ -131,3 +131,12 @@ bin-default-features = false
lib-features = ["hydrate"] lib-features = ["hydrate"]
lib-default-features = false lib-default-features = false
lib-profile-release = "wasm-release" lib-profile-release = "wasm-release"
# cargo release <level> is how a portal version is cut: bump, commit,
# tag v{{version}}, push. CI (publish.yml) reacts to the tag and
# publishes the release artifact; nothing on crates.io.
[package.metadata.release]
publish = false
push = true
tag-name = "v{{version}}"
pre-release-commit-message = "Release {{version}}"
+92 -24
View File
@@ -1,17 +1,41 @@
# portal # portal
The question runtime behind [uhhm.no](https://uhhm.no). Every page, A content-driven question engine: one Rust binary that turns a Git
form, review desk, and state machine it serves is declared in the repo of YAML into a live site — pages, forms, review desks, and state
[`questions`](https://project.uhhm.no/uhhm/questions) content repo — machines, with a durable event-sourced record of every answer
this codebase is the engine that renders, enforces, and records, and underneath. The engine special-cases nothing: everything a site
it special-cases none of it. serves is declared in its content repo, and the same build serves any
number of sites.
Point an instance at a content repo and that repo *is* the site:
pages, copy, state graphs, and branding (`site.yaml`: title,
wordmark, and which hero the landing page opens on — the YES canvas,
a gesture-drawing canvas wired to a relay, or plain copy). Content
pushes hot-reload every running instance; instances differ only by
env vars. [uhhm.no](https://uhhm.no)
([uhhm/questions](https://project.uhhm.no/uhhm/questions)) and
[redoal.com](https://redoal.com)
([redoal/questions](https://project.uhhm.no/redoal/questions)) are
two faces of the same binary, deployed from the same release
artifact.
It composes with the surrounding stack rather than bundling it: Gitea
hosts and serves the content, NATS JetStream stores events and
projections, Kanidm provides identity, and anything downstream
(automations, newsletters, onboarding) subscribes to the answer
stream.
## What the engine provides ## What the engine provides
- **Content-driven pages** (`src/content.rs`, `src/app.rs`): YAML - **Content-driven pages** (`src/content.rs`, `src/app.rs`): YAML
loaded from Gitea at boot and hot-swapped on a NATS reload signal; loaded from Gitea at boot and hot-swapped on a NATS reload signal;
a bad push keeps the last-good content serving. A page's `id` is a bad push keeps the last-good content serving. The `questions/`
its URL; `qualifies` gates it to a Kanidm group. tree IS the router — file paths become URLs, `_section.yaml`
applies criteria to a whole directory, `[name].yaml` pages serve
any `/dir/<value>` with the segment fed into resource keys, and
`requires_chain` gates a page on verifiable answer provenance next
to `qualifies`' Kanidm-group identity gate (see
`docs/design/filesystem-routes.md`).
- **State machines as content** (`src/aggregates/`): `aggregates.yaml` - **State machines as content** (`src/aggregates/`): `aggregates.yaml`
declares each bucket's states and legal transitions; the engine declares each bucket's states and legal transitions; the engine
replays a record's event history and refuses undeclared moves, with replays a record's event history and refuses undeclared moves, with
@@ -28,8 +52,13 @@ it special-cases none of it.
lineage; `?chain=` links carry it, and self-service transitions lineage; `?chain=` links carry it, and self-service transitions
(unsubscribe) authorize by holding one. (unsubscribe) authorize by holding one.
- **Live resources** (`src/resource.rs`): content can pull a KV - **Live resources** (`src/resource.rs`): content can pull a KV
bucket, Gitea starred/org repos, or any public JSON URL (SSRF bucket, Gitea starred/org repos/releases, or any public JSON URL
fail-closed), reshaped by a content-declared `jq` filter. (SSRF fail-closed), reshaped by a content-declared `jq` filter.
- **Input kinds as content**: a requirement's `type:` picks the
widget — plain fields, a rich-text editor, or a `gesture` drawing
canvas that can connect to a redoal relay so similar strokes echo
between visitors live. Submitted values are arbitrary JSON; the
engine records what the widget produced.
- **Review desks**: any Kv resource with `transitions` renders rows - **Review desks**: any Kv resource with `transitions` renders rows
with per-state action buttons and one shared confirm per with per-state action buttons and one shared confirm per
alternative — owners walk records through their graphs without alternative — owners walk records through their graphs without
@@ -38,9 +67,10 @@ it special-cases none of it.
## Binaries ## Binaries
- `portal` — the server (Leptos SSR + hydrate, Axum underneath). - `portal` — the server (Leptos SSR + hydrate, Axum underneath).
- `question_lint` — headless content validation, run by the - `question_lint` — headless content validation, shipped inside every
`questions` repo's CI against a prebuilt copy this repo's deploy release artifact so each content repo's CI lints with the exact
publishes; also works offline: `question_lint --path <dir>`. portal version its instance runs; also works offline:
`question_lint --path <dir>`.
## Development ## Development
@@ -50,17 +80,55 @@ cargo test --features ssr # engine tests
cargo build --features ssr --bin question_lint cargo build --features ssr --bin question_lint
``` ```
Runtime configuration is env vars (see `src/main.rs` and Runtime configuration is env vars (see `src/main.rs`, and each
`.gitea/workflows/deploy.yml`): `NATS_URL`, `CONTENT_REPO`/ content repo's `.gitea/workflows/deploy.yml` for the values in
`CONTENT_BRANCH`, Kanidm OIDC (`KANIDM_URL`, `OAUTH2_CLIENT_*`), production): `NATS_URL`, `CONTENT_REPO`/`CONTENT_BRANCH`, Kanidm OIDC
optional `GARAGE_*` for uploads, `GITEA_API_TOKEN` for authenticated (`KANIDM_URL`, `OAUTH2_CLIENT_*`), optional `GARAGE_*` for uploads,
resource pulls, `AUTOMATION_READ_TOKEN` for the automation KV read `GITEA_API_TOKEN` for authenticated resource pulls,
endpoint. `AUTOMATION_READ_TOKEN` for the automation KV read endpoint.
## Deploy ## Release and deploy
Pushing `main` triggers `.gitea/workflows/deploy.yml` on the Portal doesn't deploy itself — it publishes versions, and each site
bare-metal runner: release build, ship to decides when to take one. The whole day-to-day surface is two
`/srv/app/uhhm-portal/releases/<sha>`, flip the `current` symlink, commands:
restart `app@uhhm-portal`, reload Caddy. Content changes never come
through here — they hot-reload live from the `questions` repo. **Cut a release** (here):
```sh
cargo release patch # or minor / major
```
That bumps `Cargo.toml`, tags `v<version>`, and pushes; CI
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
attaches `portal-v<version>.tar.gz` (`portal` + `question_lint` +
`site/`) to the Gitea release. Plain pushes to `main` only run the
tests (`test.yml`) — nothing reaches production from this repo.
**Roll it out** (in a content repo): edit one line in that repo's
`.gitea/workflows/deploy.yml`
```yaml
env:
PORTAL_RELEASE: v0.1.1 # <- bump, commit, push
```
Its CI downloads the pinned artifact, ships
`/srv/app/<instance>/releases/<tag>`, flips `current`, rewrites the
instance env from that repo's own Actions variables/secrets, restarts
`app@<instance>`, and refreshes the Caddy route. Every rollout is a
commit, so reverting a bad version is `git revert` + push. Sites
upgrade independently: uhhm.no (uhhm/questions → `app@uhhm-portal`,
:3010) and redoal.com (redoal/questions → `app@redoal-portal`,
:3020) can pin different versions.
Content changes never come through any of this — they hot-reload
live from the content repos over NATS.
**Add a site**: new content repo with a copy of an existing
`deploy.yml` (change `INSTANCE`, port, domains), Actions variables
(`KANIDM_URL`, `OAUTH2_CLIENT_ID`, `PUBLIC_URL`, `SITE_NAME`) and
secrets (`NATS_URL`, `OAUTH2_CLIENT_SECRET`,
`PORTAL_GITEA_API_TOKEN` — Gitea reserves the `GITEA_` prefix for
secret names), a Kanidm OAuth2 client, and DNS. `site.yaml` in the
content repo handles all branding; no portal changes needed.
+133
View File
@@ -0,0 +1,133 @@
# Filesystem routes, sections, and where chains fit
Status: implemented in v0.2.0 (2026-08-24) — all three phases, with
one deviation: dynamic-page params substitute into resource keys via
server-side `resolve_question` at lookup time (get_question,
find_feature, submit_answer all resolve concrete paths), so no param
threading exists client-side. The user-facing routing contract is
documented in uhhm/questions' README ("Routing: the tree is the
router"); this file stays as the design rationale.
## What exists today, precisely
- A question's `id` doubles as its URL. Filenames are meaningless:
`questions/` is loaded as a **flat** directory (both the Gitea
loader and `question_lint --path`), every `*.yaml` becomes a
`Question` keyed by its own declared `id`.
- Hierarchy exists only as strings: `action: /proposed` edges, plus a
manual `followup: true` flag that hides post-submission pages from
the nav. The graph is invisible in a repo listing — you open every
file to learn the flow. (uhhm's actual graph: `/` fans out to three
followups; `/develop``/develop-proposal``/proposed` is a
two-step flow flattened into three top-level files.)
- Criteria are per-file: `qualifies: <kanidm-group>` on a question,
`requires_group` on a resource. Gating a whole area means
repeating the flag in every file of that area.
- Chains are query-string lineage: submitting hashes
`(question, parents, responses, ts)` into `chain_hash`, the next
page is `action + ?chain=<hash>`, and holding a chain is itself a
capability (`self_transition` + email second factor). `chain.rs`
reserves DAG shape (multiple parents) but nothing produces it yet.
- `Question.route` is a declared-but-never-read field — a fossil of
this exact idea.
## The proposal, in three phases
### Phase 1 — the tree is the router
Directory structure becomes the URL structure, next-js style:
```
questions/
index.yaml → /
applied.yaml → /applied
develop/
index.yaml → /develop
proposal.yaml → /develop/proposal
proposed.yaml → /develop/proposed
review/
index.yaml → /review
```
- `id:` becomes optional and **derived from the path** (`index.yaml`
names its directory). An explicit `id:` still wins so both content
repos keep working unchanged; lint warns when it disagrees with the
path, and the field can retire later along with `route`.
- `action:` accepts **relative references**: `action: proposed`
resolves against the file's directory, `action: /subscribed` stays
absolute. Resolution happens at load time, so validation and the
runtime see absolute ids exactly as today. A flow directory becomes
self-contained: rename `develop/` and every internal edge moves
with it.
- **`followup:` is inferred**: `index.yaml` files are nav pages,
non-index files are followups unless they say `nav: true`. This
matches the real content exactly (uhhm's four `followup: true`
files are precisely its non-index leaf pages) and deletes a flag
people must remember.
- Loader: switch from the per-directory contents API to Gitea's git
trees API (`/git/trees/{branch}?recursive=true`) — one request for
the whole tree instead of one per directory, which the flat loader
should be using anyway.
### Phase 2 — sections: criteria scoped by URL prefix
A `_section.yaml` in any directory applies to everything beneath it
(underscore = not a page, like next's private folders):
```
questions/review/_section.yaml:
qualifies: portal_owners
responsible: { name: Bendik, contact: … }
```
This is the URL/criteria interplay actually worth having: **a URL
prefix becomes a trust boundary**. "Everything under /review is
owner-only" is one line in one place, instead of a flag per file that
drifts. Per-question `qualifies` still overrides (tighter or looser —
lint should warn on looser). Sections are also the natural home for
shared `responsible` contacts and, later, per-section branding
accents.
### Phase 3 — dynamic segments, and chains stay out of the path
Two criteria axes exist: **who you are** (Kanidm group) and **what
you've done** (holding a chain). Phase 2 scopes the first by prefix;
phase 3 does the same for the second, plus gives records addresses:
- `[record].yaml` — a dynamic segment, one YAML file rendered per
record: `questions/review/[record].yaml` serves `/review/<key>`,
with the param available to the page's `ResourceSpec.key`. Today a
single record is only reachable through a desk row or a
`?chain=` link; this gives every record a real, gated URL —
linkable from review desks, automations, and n8n notifications.
- `requires_chain: <question-ref>` (page- or section-level): the page
only renders for a visitor whose chain tip answers the referenced
question. Today's followup pages are soft-hidden (out of nav) but
fully reachable; this makes "you must have come from X" an actual
criterion, declared with a relative ref like actions are.
**Deliberately not proposed: encoding the chain in the path.** The
page tree is static structure; the chain is runtime lineage and a
bearer capability. Putting it in the path makes it look canonical and
shareable — exactly what a capability URL shouldn't invite — and a
DAG (the reserved multi-parent shape) doesn't linearize into a path
anyway. `?chain=` stays a query parameter: pages keep one canonical
URL, lineage rides along only when it's actually held.
## Migration
Phase 1 is fully backward compatible (explicit `id` wins, flat repos
are just trees of depth one). The content repos migrate by `git mv`:
uhhm's develop flow nests under `develop/`, its followups either stay
top-level (`/applied` keeps its URL) or move with their flows if URL
churn is acceptable; redoal's three files are already the tree. Lint
learns the same resolution rules in the same commit, so a bad
reference stays a caught push, never a 404.
## Order of value
Phase 1 is cheap and pays immediately (the repo listing becomes the
sitemap). Phase 2 is small and unlocks gated areas properly. Phase 3
is the real feature work — `[record]` pages change what desks and
automations can link to — and can wait until something concrete needs
it.
+335
View File
@@ -0,0 +1,335 @@
// 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);
// A timeout, not requestAnimationFrame: the class must land on
// a later tick for the opacity transition to run, and rAF
// doesn't tick at all in headless engines.
setTimeout(() => thumb.classList.add('shown'), 30);
}
// ── 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);
}
+9 -4
View File
@@ -129,15 +129,17 @@ function defineGiteaRepoEmbed() {
'data-description': description, 'data-description': description,
'data-url': url, 'data-url': url,
style: style:
'display:block;border:1px solid #ddd;border-radius:8px;' + 'display:block;border:0.06em solid #ddd;border-radius:0.5em;' +
'padding:12px 16px;margin:8px 0;font-family:inherit;', 'padding:0.75em 1em;margin:0.5em 0;font-family:inherit;',
}, },
[ [
'a', 'a',
{ href: url, style: 'font-weight:600;text-decoration:none;color:inherit;' }, { href: url, style: 'font-weight:600;text-decoration:none;color:inherit;' },
`${owner}/${repo}`, `${owner}/${repo}`,
], ],
['div', { style: 'opacity:0.7;font-size:0.9em;margin-top:4px;' }, description], // Solid mid-gray, not opacity - dim on both the newsletter's
// light client background and this app's dark paper.
['div', { style: 'color:#9a9a9a;font-size:0.9rem;margin-top:0.25em;' }, description],
] ]
}, },
}) })
@@ -283,8 +285,11 @@ function buildToolbar(editor) {
} }
export function mountEditor(container, hiddenInput, initial) { export function mountEditor(container, hiddenInput, initial) {
// Only the structural stylesheet (placeholder, lists, gap cursor).
// Deliberately NOT prosekit/basic/typography.css: it sizes content in
// fixed px, which fights the app's responsive root font-size. All
// editor typography lives in our own stylesheet, in rem.
ensureStylesheet('https://esm.sh/prosekit/basic/style.css') ensureStylesheet('https://esm.sh/prosekit/basic/style.css')
ensureStylesheet('https://esm.sh/prosekit/basic/typography.css')
const extension = union( const extension = union(
defineBasicExtension(), defineBasicExtension(),
+140 -52
View File
@@ -36,8 +36,6 @@ export class RasterizedYES {
this.destroyed = false; this.destroyed = false;
this.time = 0; this.time = 0;
this.mouseX = 0.5;
this.mouseY = 0.5;
this.containmentStrength = 0.5; this.containmentStrength = 0.5;
this.wiggleAmount = 0.5; this.wiggleAmount = 0.5;
@@ -48,7 +46,9 @@ export class RasterizedYES {
this.setupCanvas(); this.setupCanvas();
this.setupResizeHandler(); this.setupResizeHandler();
this.setupMouseTracking(); this.setupDrift();
this.setupTheme();
this.setupScrollFade();
this.setupClickHandler(); this.setupClickHandler();
this.rasterizeText(); this.rasterizeText();
this.initializeLines(); this.initializeLines();
@@ -60,6 +60,82 @@ export class RasterizedYES {
if (this._resizeHandler) { if (this._resizeHandler) {
window.removeEventListener('resize', this._resizeHandler); window.removeEventListener('resize', this._resizeHandler);
} }
if (this._themeQuery) {
this._themeQuery.removeEventListener('change', this._themeHandler);
}
if (this._scrollHandler) {
window.removeEventListener('scroll', this._scrollHandler);
}
}
// The hero is position: sticky (main.css), so without this the
// title/wordmark stay pinned at the viewport bottom for the whole
// page and ghost through the translucent cards scrolling over
// them. Fade the copy out across the first half-screen of scroll;
// visibility: hidden at the end so the wordmark link can't be
// clicked while invisible.
setupScrollFade() {
this.heroCopy = this.heroEl ? this.heroEl.querySelector('.hero-copy') : null;
if (!this.heroCopy) return;
this._scrollHandler = () => {
const opacity = Math.max(0, 1 - window.scrollY / (this.displayHeight * 0.5));
this.heroCopy.style.opacity = opacity;
this.heroCopy.style.visibility = opacity <= 0.01 ? 'hidden' : '';
};
window.addEventListener('scroll', this._scrollHandler, { passive: true });
this._scrollHandler();
}
// Canvas paint can't read CSS custom properties, so the piece
// carries its own copy of both palettes and follows
// prefers-color-scheme itself - values must track main.css's :root
// (--paper especially: the fade fill IS the page background where
// the canvas shows through translucent cards). Dark keeps the
// original neon-on-black inks; light restates them as CMYK process
// inks dark enough to carry on paper, since 80%-lightness pastels
// vanish on white.
setupTheme() {
this._themeQuery = window.matchMedia('(prefers-color-scheme: light)');
this._themeHandler = () => {
this.applyTheme();
// Repaint the raster ghost in the new theme's ink and
// hard-clear the trails - a slow 3%-alpha fade from the
// old paper color would smear across the flip otherwise.
this.rasterizeText();
this.lineCtx.fillStyle = this.theme.paper;
this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight);
};
this._themeQuery.addEventListener('change', this._themeHandler);
this.applyTheme();
}
applyTheme() {
this.theme = this._themeQuery.matches
? {
paper: '#f6f5f1',
fade: 'rgba(246, 245, 241, 0.03)',
// White ground so multiply (the light theme's CSS
// blend mode for #rasterCanvas) leaves the paper
// untouched; gray ink becomes the faint YES ghost.
rasterBg: '#ffffff',
rasterInk: '#6b6b6b',
strokes: [
'hsla(185, 70%, 32%, 0.85)',
'hsla(315, 60%, 38%, 0.85)',
'hsla(50, 90%, 40%, 0.85)'
]
}
: {
paper: '#0a0a0a',
fade: 'rgba(10, 10, 10, 0.03)',
rasterBg: '#111111',
rasterInk: '#ffffff',
strokes: [
'hsla(180, 90%, 80%, 0.8)',
'hsla(300, 90%, 80%, 0.8)',
'hsla(60, 90%, 80%, 0.8)'
]
};
} }
// Reading .hero-canvas's rendered rect (a prior attempt at this) // Reading .hero-canvas's rendered rect (a prior attempt at this)
@@ -119,50 +195,59 @@ export class RasterizedYES {
window.addEventListener('resize', this._resizeHandler); window.addEventListener('resize', this._resizeHandler);
} }
setupMouseTracking() { // The two behavior dials (containmentStrength from x, wiggleAmount
const updatePosition = (clientX, clientY) => { // from y) used to follow the pointer. Now a smooth noise field
this.mouseX = clientX / this.displayWidth; // wanders them instead - the same 0..1 inputs a mouse would give,
this.mouseY = clientY / this.displayHeight; // but drifting at cloud pace, so the piece breathes on its own and
this.containmentStrength = 0.1 + (this.mouseX * 0.9); // behaves identically with nobody touching it (which on a landing
this.wiggleAmount = 0.1 + (this.mouseY * 1.9); // hero is most of the time, and on touch devices was always the
// case between taps). Value noise with two octaves: smooth
// (C1-continuous via smoothstep), never repeats visibly, no jumps.
setupDrift() {
const channel = (seed) => {
const rand = (i) => {
let h = Math.imul(i ^ seed, 2654435761) >>> 0;
h ^= h >>> 13;
h = Math.imul(h, 0x5bd1e995) >>> 0;
// The >>> 0 here is load-bearing: ^ yields a SIGNED
// 32-bit value, and without the reinterpret a set top
// bit made this "0..1" noise go as low as -0.5,
// pushing both dials below their intended floors.
h = (h ^ (h >>> 15)) >>> 0;
return h / 4294967296;
};
const noise = (t) => {
const i = Math.floor(t);
const f = t - i;
const s = f * f * (3 - 2 * f);
return rand(i) * (1 - s) + rand(i + 1) * s;
};
// Two octaves, renormalized to 0..1: the slow octave sets
// the overall weather, the faster one keeps it from
// feeling like a pendulum.
return (t) => (noise(t) * 2 / 3 + noise(t * 2.7 + 913) * 1 / 3);
}; };
window.addEventListener('mousemove', (e) => { // One full "weather change" roughly every DRIFT_PERIOD
updatePosition(e.clientX, e.clientY); // seconds per octave - the pace of watching clouds, not of a
}); // hand on a mouse.
this.driftPeriod = 25;
this.driftX = channel(0x9e3779b9);
this.driftY = channel(0x85ebca6b);
// Scoped to the canvas itself, not `window` (see below), and
// no longer calling preventDefault(): the original standalone
// page was the whole document, so blocking default touch
// behavior was harmless (nothing else to scroll to). Embedded
// as a hero above a longer page, .hero-yes covers the entire
// first screen - preventDefault() here was fighting the
// browser's own native scroll for any touch gesture starting
// in that region, which is what actually caused scrolling to
// jump/stutter (only once this component had mounted and
// attached these listeners - a plain page scroll never needed
// default prevented in the first place, just the position for
// the cosmetic line-cluster tracking below).
this.lineCanvas.addEventListener('touchmove', (e) => {
if (e.touches.length > 0) {
const touch = e.touches[0];
updatePosition(touch.clientX, touch.clientY);
}
}, { passive: true });
this.lineCanvas.addEventListener('touchstart', (e) => {
if (e.touches.length > 0) {
const touch = e.touches[0];
updatePosition(touch.clientX, touch.clientY);
}
}, { passive: true });
this.mouseX = 0.5;
this.mouseY = 0.5;
this.containmentStrength = 0.55; this.containmentStrength = 0.55;
this.wiggleAmount = 1.05; this.wiggleAmount = 1.05;
} }
updateDrift() {
const t = this.time / this.driftPeriod;
const x = this.driftX(t);
const y = this.driftY(t);
// Same mapping the mouse position used to feed.
this.containmentStrength = 0.1 + (x * 0.9);
this.wiggleAmount = 0.1 + (y * 1.9);
}
setupClickHandler() { setupClickHandler() {
this.lineCanvas.addEventListener('click', () => { this.lineCanvas.addEventListener('click', () => {
this.restartAnimation(); this.restartAnimation();
@@ -171,7 +256,7 @@ export class RasterizedYES {
restartAnimation() { restartAnimation() {
this.time = 0; this.time = 0;
this.lineCtx.fillStyle = '#0a0a0a'; this.lineCtx.fillStyle = this.theme.paper;
this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight); this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight);
this.initializeLines(); this.initializeLines();
this.isActive = true; this.isActive = true;
@@ -227,9 +312,14 @@ export class RasterizedYES {
this.letterRasters[i] = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height); this.letterRasters[i] = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height);
} }
this.rasterCtx.fillStyle = '#111'; // The visible layer, distinct from the letterRasters sampled
// above (those stay #111/#fff - isInSpecificLetter's red>128
// test depends on it): painted in theme ink so the ghost works
// under the theme's blend mode (overlay on dark, multiply on
// light - see #rasterCanvas in main.css).
this.rasterCtx.fillStyle = this.theme.rasterBg;
this.rasterCtx.fillRect(0, 0, width, height); this.rasterCtx.fillRect(0, 0, width, height);
this.rasterCtx.fillStyle = '#ffffff'; this.rasterCtx.fillStyle = this.theme.rasterInk;
this.rasterCtx.fillText('YES', textStartX, textY); this.rasterCtx.fillText('YES', textStartX, textY);
this.rasterData = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height); this.rasterData = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height);
@@ -286,19 +376,16 @@ export class RasterizedYES {
centerY = startY; centerY = startY;
} }
const colors = [ // Stroke color lives on the theme, looked up per frame by
'hsl(180, 90%, 70%)', // letterIndex (see draw()) - not frozen per line - so a
'hsl(300, 90%, 70%)', // theme flip recolors live lines instead of leaving
'hsl(60, 90%, 70%)' // dark-theme neon smearing across light paper.
];
this.lines.push({ this.lines.push({
relativeX: (startX - centerX) / this.fontSize, relativeX: (startX - centerX) / this.fontSize,
relativeY: (startY - centerY) / this.fontSize, relativeY: (startY - centerY) / this.fontSize,
prevRelativeX: (startX - centerX) / this.fontSize, prevRelativeX: (startX - centerX) / this.fontSize,
prevRelativeY: (startY - centerY) / this.fontSize, prevRelativeY: (startY - centerY) / this.fontSize,
angle: Math.random() * Math.PI * 2, angle: Math.random() * Math.PI * 2,
color: colors[letterIndex],
letterIndex: letterIndex, letterIndex: letterIndex,
lastSeenInside: { x: (startX - centerX) / this.fontSize, y: (startY - centerY) / this.fontSize }, lastSeenInside: { x: (startX - centerX) / this.fontSize, y: (startY - centerY) / this.fontSize },
outsideDuration: 0 outsideDuration: 0
@@ -347,6 +434,7 @@ export class RasterizedYES {
updateLines() { updateLines() {
this.time += 0.016; this.time += 0.016;
this.updateDrift();
if (!this.isActive) return; if (!this.isActive) return;
const letterCentroids = this.calculateLetterCentroids(); const letterCentroids = this.calculateLetterCentroids();
@@ -454,7 +542,7 @@ export class RasterizedYES {
} }
draw() { draw() {
this.lineCtx.fillStyle = 'rgba(10, 10, 10, 0.03)'; this.lineCtx.fillStyle = this.theme.fade;
this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight); this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight);
const letterCentroids = this.calculateLetterCentroids(); const letterCentroids = this.calculateLetterCentroids();
@@ -470,7 +558,7 @@ export class RasterizedYES {
if (prevX === currentX && prevY === currentY) return; if (prevX === currentX && prevY === currentY) return;
this.lineCtx.strokeStyle = line.color.replace('70%)', '80%, 0.8)'); this.lineCtx.strokeStyle = this.theme.strokes[line.letterIndex];
this.lineCtx.lineWidth = this.fontSize * 0.003; this.lineCtx.lineWidth = this.fontSize * 0.003;
this.lineCtx.lineCap = 'round'; this.lineCtx.lineCap = 'round';
+2 -2
View File
@@ -101,7 +101,7 @@ pub struct TransitionItem {
/// (the shared per-alternative Confirm button). Items are independent /// (the shared per-alternative Confirm button). Items are independent
/// - per-item CAS, no cross-item transaction - so one failure doesn't /// - per-item CAS, no cross-item transaction - so one failure doesn't
/// roll back the others; failures are collected and reported together. /// roll back the others; failures are collected and reported together.
#[server] #[server(endpoint = "transition_answers")]
pub async fn transition_answers( pub async fn transition_answers(
question_id: String, question_id: String,
alternative: String, alternative: String,
@@ -289,7 +289,7 @@ async fn apply_transition(
/// itself. Deliberately returns the same generic error for "no such /// itself. Deliberately returns the same generic error for "no such
/// item" and "email doesn't match", so an unsubscribe link can't be /// item" and "email doesn't match", so an unsubscribe link can't be
/// used to probe which hashes or emails exist. /// used to probe which hashes or emails exist.
#[server] #[server(endpoint = "self_transition_answer")]
pub async fn self_transition_answer( pub async fn self_transition_answer(
question_id: String, question_id: String,
alternative: String, alternative: String,
+324 -30
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
@@ -81,10 +81,26 @@ fn QuestionPage() -> impl IntoView {
let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain"))); let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain")));
let query_email = Memo::new(move |_| query.with(|q| q.get("email"))); let query_email = Memo::new(move |_| query.with(|q| q.get("email")));
let question = Resource::new(move || path.get(), get_question); let question = Resource::new(
move || (path.get(), parent_hash.get()),
|(path, chain)| get_question(path, chain),
);
let user = Resource::new(|| (), |_| current_user()); let user = Resource::new(|| (), |_| current_user());
let site = Resource::new(|| (), |_| get_site());
view! { view! {
// The content-declared site title lives OUTSIDE the question
// Suspense: tied to the per-page resource it unmounted on every
// SPA navigation and lost the leptos_meta race to App's
// compile-time SITE_NAME fallback (redoal.com flashing to
// "uhhm" on nav - and staying there). Out here it mounts once
// and covers every branch, NotFound included.
{move || {
site.get()
.and_then(|r| r.ok())
.and_then(|s| s.title)
.map(|t| view! { <Title text=t/> })
}}
<Suspense fallback=|| { <Suspense fallback=|| {
view! { view! {
<main class="loading"> <main class="loading">
@@ -99,16 +115,19 @@ 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(page)) => {
let current = user_res.and_then(|r| r.ok()).flatten(); let current = user_res.and_then(|r| r.ok()).flatten();
view! { view! {
<QuestionView <QuestionView
question=q question=page.question
chain_gate=page.chain_gate
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()
@@ -123,12 +142,43 @@ fn QuestionPage() -> impl IntoView {
#[component] #[component]
fn QuestionView( fn QuestionView(
question: Question, question: Question,
chain_gate: Option<(String, String)>,
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();
// The provenance counterpart to the qualifies gate below: a
// requires_chain page whose visitor holds no verifiable lineage to
// the required question renders a pointer there instead of its
// alternatives.
if let Some((target, target_name)) = chain_gate {
let label = if target_name.is_empty() {
target.clone()
} else {
target_name
};
return view! {
<Hero
title=question.name.clone()
description=question.description.clone()
landing=question_id == "/"
site=site.clone()
/>
<div class="alternatives">
<section class="alt-card gate-card">
<p>"This page follows from an answer you don't seem to carry yet."</p>
<a class="alt-submit" href=target>
{label}
</a>
</section>
</div>
}
.into_any();
}
// Generic: any question with `qualifies` set renders this same gate // Generic: any question with `qualifies` set renders this same gate
// instead of its alternatives - "/review" isn't a special case, it's // instead of its alternatives - "/review" isn't a special case, it's
// just a question that happens to have `qualifies` set. // just a question that happens to have `qualifies` set.
@@ -138,7 +188,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 +226,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
@@ -309,7 +361,12 @@ fn ResponsibleNote(responsible: Responsible) -> impl IntoView {
mod yes { mod yes {
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/yes.js")] // raw_module (a runtime URL, not a bundled snippet) on purpose:
// the file lives in public/ so it's served at the stable /yes.js
// - the same URL the hero's inline early-mount script imports.
// A bundled snippet would sit under a per-build hashed
// /pkg/snippets/ path the inline script couldn't know.
#[wasm_bindgen(raw_module = "/yes.js")]
extern "C" { extern "C" {
#[wasm_bindgen(js_name = RasterizedYES)] #[wasm_bindgen(js_name = RasterizedYES)]
pub type RasterizedYes; pub type RasterizedYes;
@@ -347,16 +404,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),
@@ -380,7 +478,28 @@ fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView {
if raster_ref.get().is_none() { if raster_ref.get().is_none() {
return; return;
} }
instance.set_value(Some(yes::RasterizedYes::new())); if instance.with_value(|i| i.is_some()) {
return;
}
// Adopt the hero's inline early-mount instance (started at
// HTML parse time, long before this wasm was even fetched)
// instead of starting a second animation. The flag covers
// the opposite ordering too: an inline script that runs
// after this sees it and stays inert.
use wasm_bindgen::JsCast;
let global = js_sys::global();
let _ = js_sys::Reflect::set(&global, &"__yesAdopted".into(), &true.into());
let early = js_sys::Reflect::get(&global, &"__yesEarly".into())
.ok()
.filter(|v| !v.is_undefined() && !v.is_null());
let inst = match early {
Some(v) => {
let _ = js_sys::Reflect::delete_property(&global, &"__yesEarly".into());
v.unchecked_into::<yes::RasterizedYes>()
}
None => yes::RasterizedYes::new(),
};
instance.set_value(Some(inst));
}); });
on_cleanup(move || { on_cleanup(move || {
instance.update_value(|opt| { instance.update_value(|opt| {
@@ -391,8 +510,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! {
@@ -400,11 +546,24 @@ fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView {
<canvas id="lineCanvas"></canvas> <canvas id="lineCanvas"></canvas>
<canvas id="rasterCanvas" node_ref=raster_ref></canvas> <canvas id="rasterCanvas" node_ref=raster_ref></canvas>
</div> </div>
// Starts the animation at HTML parse time
// instead of waiting out the wasm bundle's
// fetch + hydration; the hydrate Effect above
// adopts (never duplicates) the instance, and
// the guards make either execution order safe.
<script
type="module"
inner_html="import('/yes.js').then((m) => { if (!window.__yesAdopted && !window.__yesEarly) window.__yesEarly = new m.RasterizedYES(); });"
></script>
} }
})} })}
{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 +695,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 +709,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 +737,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 +764,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 +1026,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();
@@ -1484,18 +1714,73 @@ fn NotFound() -> impl IntoView {
} }
} }
#[server] /// What a URL resolves to: the question (dynamic pages arrive with
pub async fn get_question(path: String) -> Result<Option<Question>, ServerFnError> { /// their segment value already substituted, see
/// `content::resolve_question`) plus whether a `requires_chain` gate
/// blocked it - `(target id, target name)` so the gate can point the
/// visitor at where the required answer comes from.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Page {
pub question: Question,
pub chain_gate: Option<(String, String)>,
}
#[server(endpoint = "get_question")]
pub async fn get_question(
path: String,
chain: Option<String>,
) -> Result<Option<Page>, ServerFnError> {
use crate::content::{path_matches, resolve_question};
use crate::server::AppState; use crate::server::AppState;
let state = expect_context::<AppState>(); let state = expect_context::<AppState>();
Ok(state.questions.load().get(&path).cloned()) let questions = state.questions.load();
let Some(question) = resolve_question(&questions, &path) else {
return Ok(None);
};
let mut chain_gate = None;
if let Some(target) = &question.requires_chain {
// The claimed lineage must verifiably end at the required
// question - an unindexed or absent hash reads as unverified,
// and the gate stays shut. Dynamic targets match any concrete
// answer of theirs.
let verified = match &chain {
Some(hash) => crate::chain::lookup_node(&state.jetstream, hash)
.await
.is_some_and(|node| {
node.question_id == *target
|| path_matches(target, &node.question_id).is_some()
}),
None => false,
};
if !verified {
let name = questions
.get(target)
.map(|q| q.name.clone())
.unwrap_or_default();
chain_gate = Some((target.clone(), name));
}
}
Ok(Some(Page {
question,
chain_gate,
}))
}
/// 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
/// carries an answer chain. /// carries an answer chain.
#[server] #[server(endpoint = "list_qualifying_questions")]
pub async fn list_qualifying_questions( pub async fn list_qualifying_questions(
has_chain: bool, has_chain: bool,
) -> Result<Vec<(String, String)>, ServerFnError> { ) -> Result<Vec<(String, String)>, ServerFnError> {
@@ -1515,7 +1800,9 @@ pub async fn list_qualifying_questions(
.load() .load()
.values() .values()
.filter(|q| is_qualified(user.as_ref(), q)) .filter(|q| is_qualified(user.as_ref(), q))
.filter(|q| !q.followup || has_chain) .filter(|q| !q.is_followup() || has_chain)
// A dynamic page has no URL of its own to link to.
.filter(|q| !q.is_dynamic())
.map(|q| (q.id.clone(), q.name.clone())) .map(|q| (q.id.clone(), q.name.clone()))
.collect(); .collect();
out.sort(); out.sort();
@@ -1528,7 +1815,7 @@ pub struct SubmitResult {
pub chain_hash: String, pub chain_hash: String,
} }
#[server] #[server(endpoint = "submit_answer")]
pub async fn submit_answer( pub async fn submit_answer(
question_id: String, question_id: String,
alternative: String, alternative: String,
@@ -1543,11 +1830,10 @@ pub async fn submit_answer(
use crate::server::AppState; use crate::server::AppState;
let state = expect_context::<AppState>(); let state = expect_context::<AppState>();
let question = state // resolve_question, not a plain map get: a dynamic page's concrete
.questions // path (what the client holds as its question id) resolves to the
.load() // pattern page it came from.
.get(&question_id) let question = crate::content::resolve_question(&state.questions.load(), &question_id)
.cloned()
.ok_or_else(|| ServerFnError::new("unknown question"))?; .ok_or_else(|| ServerFnError::new("unknown question"))?;
let session: tower_sessions::Session = leptos_axum::extract().await?; let session: tower_sessions::Session = leptos_axum::extract().await?;
@@ -1588,6 +1874,14 @@ pub async fn submit_answer(
.await .await
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?; .map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
// Index the node so requires_chain pages can verify lineage.
// Best-effort: the NATS event above is the durable record.
if let Err(e) =
crate::chain::record_node(&state.jetstream, &chain_hash, &question_id, timestamp_ms).await
{
tracing::error!("failed to index chain node: {e}");
}
// Best-effort: a bucket-write hiccup shouldn't fail a submission the // Best-effort: a bucket-write hiccup shouldn't fail a submission the
// NATS event has already recorded. // NATS event has already recorded.
if let Some(bucket) = &record_as { if let Some(bucket) = &record_as {
+1 -1
View File
@@ -19,7 +19,7 @@ pub struct User {
pub const SESSION_USER_KEY: &str = "user"; pub const SESSION_USER_KEY: &str = "user";
/// Returns the currently signed-in user, if any. /// Returns the currently signed-in user, if any.
#[server] #[server(endpoint = "current_user")]
pub async fn current_user() -> Result<Option<User>, ServerFnError> { pub async fn current_user() -> Result<Option<User>, ServerFnError> {
let session: tower_sessions::Session = leptos_axum::extract().await?; let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session let user = session
+53 -15
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
@@ -85,23 +108,38 @@ fn load_aggregates_from_dir(
} }
/// The offline counterpart to `content::load_questions_from_gitea` - /// The offline counterpart to `content::load_questions_from_gitea` -
/// same "every `*.yaml` file becomes a `Question` keyed by its own /// the same recursive tree walk and `content::build_questions`
/// `id`" shape, just reading a local checkout instead of Gitea's API, /// pipeline (derived ids, relative refs, sections, followup
/// inference), just reading a local checkout instead of Gitea's API,
/// for linting a branch that hasn't been pushed yet. /// for linting a branch that hasn't been pushed yet.
fn load_from_dir( fn load_from_dir(
dir: &str, dir: &str,
) -> anyhow::Result<std::collections::HashMap<String, content::Question>> { ) -> anyhow::Result<std::collections::HashMap<String, content::Question>> {
let mut out = std::collections::HashMap::new(); let base = std::path::Path::new(dir);
let mut files = Vec::new();
collect_yaml(base, base, &mut files)?;
content::build_questions(&files)
}
fn collect_yaml(
base: &std::path::Path,
dir: &std::path::Path,
out: &mut Vec<(String, String)>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? { for entry in std::fs::read_dir(dir)? {
let entry = entry?; let path = entry?.path();
let path = entry.path(); if path.is_dir() {
if path.extension().and_then(|e| e.to_str()) != Some("yaml") { collect_yaml(base, &path, out)?;
continue; } else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
let rel = path
.strip_prefix(base)
.expect("walked paths sit under their base")
.to_string_lossy()
.replace('\\', "/");
let raw = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
out.push((rel, raw));
} }
let raw = std::fs::read_to_string(&path)?;
let question: content::Question = serde_yaml::from_str(&raw)
.map_err(|e| anyhow::anyhow!("parsing {}: {e}", path.display()))?;
out.insert(question.id.clone(), question);
} }
Ok(out) Ok(())
} }
+54
View File
@@ -9,6 +9,60 @@ use sha2::{Digest, Sha256};
/// submitted responses, and a timestamp. Parents are sorted first so the /// submitted responses, and a timestamp. Parents are sorted first so the
/// hash doesn't depend on the order multiple parents happened to arrive /// hash doesn't depend on the order multiple parents happened to arrive
/// in. /// in.
/// Where submitted chain nodes are indexed - hash → which question was
/// answered. Small on purpose (no responses), and shared by every
/// portal instance on the JetStream (hashes are globally unique, so
/// cross-site collisions can't happen). This is what lets
/// `requires_chain` pages verify a visitor's `?chain=` actually ends
/// at the question they claim to have answered.
pub const CHAIN_BUCKET: &str = "portal_chains";
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ChainNode {
pub question_id: String,
pub timestamp_ms: i64,
}
/// Indexes one submitted node. Best-effort by design (the caller logs
/// and continues): the NATS event is the durable record, this is a
/// lookup convenience.
pub async fn record_node(
js: &async_nats::jetstream::Context,
chain_hash: &str,
question_id: &str,
timestamp_ms: i64,
) -> anyhow::Result<()> {
let store = match js.get_key_value(CHAIN_BUCKET).await {
Ok(store) => store,
Err(_) => {
js.create_key_value(async_nats::jetstream::kv::Config {
bucket: CHAIN_BUCKET.to_string(),
..Default::default()
})
.await?
}
};
let node = ChainNode {
question_id: question_id.to_string(),
timestamp_ms,
};
store.put(chain_hash, serde_json::to_vec(&node)?.into()).await?;
Ok(())
}
/// Looks a chain hash up - `None` covers both "no such node" and
/// "bucket not created yet" (no submissions anywhere), which read the
/// same to a `requires_chain` check: the claimed lineage can't be
/// verified, so the gate stays shut.
pub async fn lookup_node(
js: &async_nats::jetstream::Context,
chain_hash: &str,
) -> Option<ChainNode> {
let store = js.get_key_value(CHAIN_BUCKET).await.ok()?;
let bytes = store.get(chain_hash).await.ok()??;
serde_json::from_slice(&bytes).ok()
}
pub fn hash_node( pub fn hash_node(
question_id: &str, question_id: &str,
parent_hashes: &[String], parent_hashes: &[String],
+703 -39
View File
@@ -1,35 +1,85 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// One page: a prompt plus the paths on from it. `id` doubles as the URL /// One page: a prompt plus the paths on from it. The `questions/`
/// path it's served at ("/" is the landing page). Loaded from a plain /// directory tree IS the URL tree (`index.yaml` names its directory,
/// YAML file per question in a content directory kept in its own git /// `develop/proposal.yaml` serves `/develop/proposal`), so `id` is
/// repo (see ../portal-content) - editing content is a content-repo /// derived from the file's path - declaring it explicitly still works
/// commit, not a Rust rebuild. /// (and wins, with a logged warning when it disagrees) but is only
/// needed by legacy content. Loaded from plain YAML files in a content
/// directory kept in its own git repo - editing content is a
/// content-repo commit, not a Rust rebuild.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Question { pub struct Question {
pub id: String, /// The URL path this page is served at. Derived from the file path
/// when absent. A `[name]` segment (from a `[name].yaml` file)
/// makes this a dynamic page: `/review/[record]` serves any
/// `/review/<value>`, with the value substituted into `{record}`
/// placeholders in the page's resource keys (see
/// `resolve_question`).
#[serde(default)] #[serde(default)]
pub route: Option<String>, pub id: String,
pub name: String, pub name: String,
#[serde(default)] #[serde(default)]
pub description: String, pub description: String,
/// Kanidm group required to view/submit this question - `None` means /// Kanidm group required to view/submit this question - `None` means
/// open to anyone, matching every question today. Content-driven /// open to anyone. Content-driven on purpose: a gated page like
/// on purpose: a gated page like "/review" is just a Question with /// "/review" is just a Question with this set, not a bespoke Rust
/// this set, not a bespoke Rust route. /// route. Inherited from the nearest ancestor `_section.yaml` when
/// not set on the question itself.
#[serde(default)] #[serde(default)]
pub qualifies: Option<String>, pub qualifies: Option<String>,
/// Question id (relative refs resolve against this file's
/// directory) the visitor must have answered - their `?chain=`
/// lineage's tip - to see this page. The provenance counterpart to
/// `qualifies`' identity check. Inherited from `_section.yaml`
/// like `qualifies`.
#[serde(default)]
pub requires_chain: Option<String>,
#[serde(default)] #[serde(default)]
pub alternatives: Vec<Alternative>, pub alternatives: Vec<Alternative>,
/// Who to contact if a visitor gets stuck - rendered as a small line /// Who to contact if a visitor gets stuck - rendered as a small line
/// on the page. /// on the page. Inherited from `_section.yaml` when not set.
#[serde(default)] #[serde(default)]
pub responsible: Option<Responsible>, pub responsible: Option<Responsible>,
/// A page that only makes sense after answering something (the /// A page that only makes sense after answering something (the
/// post-submission pages) - kept out of the question nav unless the /// post-submission pages) - kept out of the question nav unless the
/// visitor's context carries an answer chain. /// visitor's context carries an answer chain. Unset means inferred
/// from the file's place in the tree: files nested in a
/// subdirectory are followups unless they're the directory's
/// `index.yaml`; top-level files keep the historical default
/// (not a followup).
#[serde(default)] #[serde(default)]
pub followup: bool, pub followup: Option<bool>,
}
impl Question {
pub fn is_followup(&self) -> bool {
self.followup.unwrap_or(false)
}
/// A dynamic page - one whose id still contains a `[name]`
/// segment. Served per-value via `resolve_question`, never listed
/// in nav, never a valid `action` target.
pub fn is_dynamic(&self) -> bool {
self.id.contains('[')
}
}
/// Directory-scoped defaults: a `_section.yaml` file applies to every
/// question at or below its directory (nearest ancestor wins per
/// field, and a question's own declaration always overrides). This is
/// what makes a URL prefix a trust boundary - "everything under
/// /review is owner-only" is one line in `review/_section.yaml`
/// instead of a flag per file. Underscore-prefixed files that aren't
/// `_section.yaml` are skipped entirely (drafts).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct SectionConfig {
#[serde(default)]
pub qualifies: Option<String>,
#[serde(default)]
pub requires_chain: Option<String>,
#[serde(default)]
pub responsible: Option<Responsible>,
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -163,6 +213,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 +233,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 +287,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 +328,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")]
@@ -368,6 +529,228 @@ pub async fn load_aggregates_from_gitea(
/// startup, and again on every `CONTENT_RELOAD_SUBJECT` message (see /// startup, and again on every `CONTENT_RELOAD_SUBJECT` message (see
/// `watch_for_reload`), over Gitea's public contents API (no auth - the /// `watch_for_reload`), over Gitea's public contents API (no auth - the
/// content repo is public). /// content repo is public).
/// The URL a file at `rel` (path relative to the questions dir, forward
/// slashes) serves: `index.yaml` names its directory, everything else
/// appends its stem.
pub fn route_from_path(rel: &str) -> String {
let stem = rel.strip_suffix(".yaml").unwrap_or(rel);
let mut segments: Vec<&str> = stem.split('/').collect();
if segments.last() == Some(&"index") {
segments.pop();
}
if segments.is_empty() {
"/".to_string()
} else {
format!("/{}", segments.join("/"))
}
}
/// The directory (as a URL prefix) of the file at `rel` - the base
/// relative references resolve against.
pub fn dir_from_path(rel: &str) -> String {
match rel.rsplit_once('/') {
Some((dir, _)) => format!("/{dir}"),
None => "/".to_string(),
}
}
/// Resolves a possibly-relative question reference (`action:`,
/// `requires_chain:`) against the referencing file's directory:
/// `/x` is absolute, `proposed` names a sibling, `../x` climbs.
pub fn resolve_ref(base_dir: &str, reference: &str) -> String {
if reference.starts_with('/') {
return reference.to_string();
}
let mut segments: Vec<&str> = base_dir.split('/').filter(|s| !s.is_empty()).collect();
for part in reference.split('/') {
match part {
"" | "." => {}
".." => {
segments.pop();
}
s => segments.push(s),
}
}
if segments.is_empty() {
"/".to_string()
} else {
format!("/{}", segments.join("/"))
}
}
/// Whether `path` matches `pattern`, capturing `[name]` segments.
/// Returns the captured (name, value) pairs on a match - empty for an
/// exact literal match.
pub fn path_matches(pattern: &str, path: &str) -> Option<Vec<(String, String)>> {
let pat: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
let got: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if pat.len() != got.len() {
return None;
}
let mut captures = Vec::new();
for (p, g) in pat.iter().zip(got.iter()) {
if let Some(name) = p.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
if g.is_empty() {
return None;
}
captures.push((name.to_string(), (*g).to_string()));
} else if p != g {
return None;
}
}
Some(captures)
}
/// Resolves a URL path to its question: an exact id first, else the
/// one dynamic page whose pattern matches, with each captured segment
/// substituted into `{name}` placeholders in the clone's resource keys
/// and its id set to the concrete path (so every follow-up server call
/// - resources, submissions - re-resolves the same way). The
/// substituted key is still looked up in the content-declared bucket,
/// so a visitor varies the key, never the bucket - the same trust
/// shape as a `?chain=` link, where knowing a record's key is holding
/// it.
pub fn resolve_question(
questions: &std::collections::HashMap<String, Question>,
path: &str,
) -> Option<Question> {
if let Some(q) = questions.get(path) {
return Some(q.clone());
}
for q in questions.values() {
if !q.is_dynamic() {
continue;
}
if let Some(captures) = path_matches(&q.id, path) {
let mut resolved = q.clone();
resolved.id = path.to_string();
let substitute = |key: &mut Option<String>| {
if let Some(k) = key {
for (name, value) in &captures {
*k = k.replace(&format!("{{{name}}}"), value);
}
}
};
for alt in &mut resolved.alternatives {
for feature in &mut alt.features {
if let Some(res) = &mut feature.resource {
substitute(&mut res.key);
}
for req in &mut feature.requirements {
if let Some(res) = &mut req.resource {
substitute(&mut res.key);
}
}
}
}
return Some(resolved);
}
}
None
}
/// Builds the question map from raw (path, yaml) files - the shared
/// back half of both loaders (Gitea tree and `question_lint --path`).
/// Applies the whole filesystem-routing contract: derived ids,
/// relative-reference resolution, followup inference, `_section.yaml`
/// inheritance, and the tree-shape rules (no id collisions, at most
/// one dynamic page per directory).
#[cfg(feature = "ssr")]
pub fn build_questions(
files: &[(String, String)],
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
// Sections first: (directory prefix, config), shallowest first so a
// later (deeper) section overrides an outer one field-by-field.
let mut sections: Vec<(String, SectionConfig)> = Vec::new();
for (rel, raw) in files {
let name = rel.rsplit('/').next().unwrap_or(rel);
if name != "_section.yaml" {
continue;
}
let mut section: SectionConfig = serde_yaml::from_str(raw)
.map_err(|e| anyhow::anyhow!("parsing {rel}: {e}"))?;
let dir = dir_from_path(rel);
if let Some(rc) = &section.requires_chain {
section.requires_chain = Some(resolve_ref(&dir, rc));
}
sections.push((dir, section));
}
sections.sort_by_key(|(dir, _)| dir.len());
let mut out = std::collections::HashMap::new();
let mut dynamic_dirs = std::collections::HashSet::new();
for (rel, raw) in files {
let name = rel.rsplit('/').next().unwrap_or(rel);
// Underscore files are sections or drafts, never pages.
if name.starts_with('_') {
continue;
}
let mut question: Question =
serde_yaml::from_str(raw).map_err(|e| anyhow::anyhow!("parsing {rel}: {e}"))?;
let derived = route_from_path(rel);
let dir = dir_from_path(rel);
if question.id.is_empty() {
question.id = derived.clone();
} else if question.id != derived {
tracing::warn!(
"{rel}: declared id {:?} disagrees with its path ({derived}) - the declared id wins, but consider moving the file",
question.id
);
}
for alt in &mut question.alternatives {
if let Some(action) = &alt.action {
alt.action = Some(resolve_ref(&dir, action));
}
}
if let Some(rc) = &question.requires_chain {
question.requires_chain = Some(resolve_ref(&dir, rc));
}
if question.followup.is_none() {
// Nested non-index files are flow steps and outcomes -
// followups by construction. Top-level files keep the
// historical flat-repo default.
let nested = rel.contains('/');
let is_index = name == "index.yaml";
question.followup = Some(nested && !is_index);
}
// Nearest-ancestor section fills whatever the question left
// unset (walk deepest-last, so later matches override earlier
// section values but never the question's own).
for (sdir, section) in &sections {
let applies = *sdir == "/" || dir == *sdir || dir.starts_with(&format!("{sdir}/"));
if !applies {
continue;
}
if question.qualifies.is_none() {
question.qualifies = section.qualifies.clone();
}
if question.requires_chain.is_none() {
question.requires_chain = section.requires_chain.clone();
}
if question.responsible.is_none() {
question.responsible = section.responsible.clone();
}
}
if question.is_dynamic() && !dynamic_dirs.insert(dir.clone()) {
anyhow::bail!(
"{rel}: more than one dynamic ([name].yaml) page in {dir} - matching would be ambiguous"
);
}
if let Some(previous) = out.insert(question.id.clone(), question) {
anyhow::bail!(
"{rel}: id {:?} is already declared by another file",
previous.id
);
}
}
Ok(out)
}
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub async fn load_questions_from_gitea( pub async fn load_questions_from_gitea(
repo_url: &str, repo_url: &str,
@@ -378,45 +761,59 @@ pub async fn load_questions_from_gitea(
let api_base = gitea_api_base(repo_url)?; let api_base = gitea_api_base(repo_url)?;
let client = openidconnect::reqwest::Client::new(); let client = openidconnect::reqwest::Client::new();
let list_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/contents/{subdir}?ref={branch}"); // One recursive git-trees call for the whole repo instead of a
// contents listing per directory - the tree IS the router now, so
// nested files matter.
let tree_url =
format!("{api_base}/api/v1/repos/{owner}/{repo}/git/trees/{branch}?recursive=true");
let listing = client let listing = client
.get(&list_url) .get(&tree_url)
.send() .send()
.await .await
.map_err(|e| anyhow::anyhow!("listing {list_url}: {e}"))? .map_err(|e| anyhow::anyhow!("listing {tree_url}: {e}"))?
.error_for_status() .error_for_status()
.map_err(|e| anyhow::anyhow!("listing {list_url}: {e}"))? .map_err(|e| anyhow::anyhow!("listing {tree_url}: {e}"))?
.text() .text()
.await .await
.map_err(|e| anyhow::anyhow!("reading directory listing from {list_url}: {e}"))?; .map_err(|e| anyhow::anyhow!("reading tree from {tree_url}: {e}"))?;
let entries: Vec<serde_json::Value> = serde_json::from_str(&listing) let tree: serde_json::Value = serde_json::from_str(&listing)
.map_err(|e| anyhow::anyhow!("parsing directory listing from {list_url}: {e}"))?; .map_err(|e| anyhow::anyhow!("parsing tree from {tree_url}: {e}"))?;
if tree.get("truncated").and_then(|v| v.as_bool()) == Some(true) {
anyhow::bail!("git tree listing for {owner}/{repo} was truncated - repo too large");
}
let prefix = format!("{subdir}/");
let mut out = std::collections::HashMap::new(); let mut files = Vec::new();
for entry in entries { for entry in tree
let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or(""); .get("tree")
if !name.ends_with(".yaml") { .and_then(|v| v.as_array())
.map(|v| v.as_slice())
.unwrap_or_default()
{
let path = entry.get("path").and_then(|v| v.as_str()).unwrap_or("");
if entry.get("type").and_then(|v| v.as_str()) != Some("blob")
|| !path.starts_with(&prefix)
|| !path.ends_with(".yaml")
{
continue; continue;
} }
let download_url = entry // Brackets (dynamic pages like `[record].yaml`) must be
.get("download_url") // percent-encoded in the raw URL's path.
.and_then(|v| v.as_str()) let encoded = path.replace('[', "%5B").replace(']', "%5D");
.ok_or_else(|| anyhow::anyhow!("no download_url for {name}"))?; let raw_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/raw/{encoded}?ref={branch}");
let raw = client let raw = client
.get(download_url) .get(&raw_url)
.send() .send()
.await .await
.map_err(|e| anyhow::anyhow!("fetching {name}: {e}"))? .map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))?
.error_for_status() .error_for_status()
.map_err(|e| anyhow::anyhow!("fetching {name}: {e}"))? .map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))?
.text() .text()
.await .await
.map_err(|e| anyhow::anyhow!("reading {name}: {e}"))?; .map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
let question: Question = files.push((path[prefix.len()..].to_string(), raw));
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {name}: {e}"))?;
out.insert(question.id.clone(), question);
} }
Ok(out) build_questions(&files)
} }
/// Validates every declared transition target (`SelfTransition.to`, /// Validates every declared transition target (`SelfTransition.to`,
@@ -435,15 +832,31 @@ pub fn validate_questions(
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>, aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
for question in questions.values() { for question in questions.values() {
if let Some(target) = &question.requires_chain {
if !questions.contains_key(target) {
anyhow::bail!(
"question {:?}: requires_chain {:?} does not match any declared question id",
question.id,
target
);
}
}
for alternative in &question.alternatives { for alternative in &question.alternatives {
// A dangling action is a literal dead end: the submit // A dangling action is a literal dead end: the submit
// button navigates to "Nothing here". // button navigates to "Nothing here".
if let Some(action) = &alternative.action { if let Some(action) = &alternative.action {
if !questions.contains_key(action) { match questions.get(action) {
anyhow::bail!( None => anyhow::bail!(
"question {:?} alternative {:?}: action {:?} does not match any declared question id", "question {:?} alternative {:?}: action {:?} does not match any declared question id",
question.id, alternative.name, action question.id, alternative.name, action
); ),
// A dynamic page needs a concrete segment value to
// be a URL - a submit button can't supply one.
Some(target) if target.is_dynamic() => anyhow::bail!(
"question {:?} alternative {:?}: action {:?} targets a dynamic page - actions must name a concrete question",
question.id, alternative.name, action
),
Some(_) => {}
} }
} }
if let Some(st) = &alternative.self_transition { if let Some(st) = &alternative.self_transition {
@@ -472,6 +885,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 +976,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 +994,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 +1014,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 +1295,222 @@ 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");
}
#[test]
fn routes_derive_from_paths() {
assert_eq!(route_from_path("index.yaml"), "/");
assert_eq!(route_from_path("applied.yaml"), "/applied");
assert_eq!(route_from_path("develop/index.yaml"), "/develop");
assert_eq!(route_from_path("develop/proposal.yaml"), "/develop/proposal");
assert_eq!(route_from_path("review/[record].yaml"), "/review/[record]");
}
#[test]
fn relative_refs_resolve_against_the_file_dir() {
assert_eq!(resolve_ref("/develop", "proposed"), "/develop/proposed");
assert_eq!(resolve_ref("/develop", "/subscribed"), "/subscribed");
assert_eq!(resolve_ref("/develop", "../applied"), "/applied");
assert_eq!(resolve_ref("/", "applied"), "/applied");
assert_eq!(resolve_ref("/", ".."), "/");
}
#[test]
fn dynamic_patterns_capture_segments() {
assert_eq!(path_matches("/review/[record]", "/review/abc"),
Some(vec![("record".into(), "abc".into())]));
assert_eq!(path_matches("/review/[record]", "/review"), None);
assert_eq!(path_matches("/review/[record]", "/other/abc"), None);
assert_eq!(path_matches("/review", "/review"), Some(vec![]));
}
#[test]
fn tree_becomes_router_with_inference_and_sections() {
let files = vec![
("index.yaml".to_string(), "name: Home\nalternatives:\n - name: go\n action: applied\n".to_string()),
("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()),
("develop/index.yaml".to_string(), "name: Develop\nalternatives:\n - name: propose\n action: proposal\n".to_string()),
("develop/proposal.yaml".to_string(), "name: Proposal\nalternatives:\n - name: send\n action: proposed\n".to_string()),
("develop/proposed.yaml".to_string(), "name: Proposed\n".to_string()),
("review/_section.yaml".to_string(), "qualifies: portal_owners\n".to_string()),
("review/index.yaml".to_string(), "name: Review\n".to_string()),
("review/_draft.yaml".to_string(), "not even valid yaml: [\n".to_string()),
];
let questions = build_questions(&files).unwrap();
// Derived ids and resolved relative actions.
assert_eq!(questions["/"].alternatives[0].action.as_deref(), Some("/applied"));
assert_eq!(
questions["/develop"].alternatives[0].action.as_deref(),
Some("/develop/proposal")
);
assert_eq!(
questions["/develop/proposal"].alternatives[0].action.as_deref(),
Some("/develop/proposed")
);
// Followup inference: nested non-index files are followups,
// index files and top-level files are not (explicit wins).
assert!(!questions["/"].is_followup());
assert!(questions["/applied"].is_followup());
assert!(!questions["/develop"].is_followup());
assert!(questions["/develop/proposal"].is_followup());
// Section inheritance gates the directory; drafts are skipped.
assert_eq!(questions["/review"].qualifies.as_deref(), Some("portal_owners"));
assert_eq!(questions["/"].qualifies, None);
assert!(!questions.contains_key("/review/_draft"));
}
#[test]
fn declared_id_wins_over_path() {
let files = vec![(
"legacy.yaml".to_string(),
"id: /somewhere-else\nname: Legacy\n".to_string(),
)];
let questions = build_questions(&files).unwrap();
assert!(questions.contains_key("/somewhere-else"));
}
#[test]
fn colliding_ids_and_ambiguous_dynamics_are_rejected() {
let collision = vec![
("a.yaml".to_string(), "name: A\n".to_string()),
("b.yaml".to_string(), "id: /a\nname: B\n".to_string()),
];
assert!(build_questions(&collision).is_err());
let ambiguous = vec![
("review/[a].yaml".to_string(), "name: A\n".to_string()),
("review/[b].yaml".to_string(), "name: B\n".to_string()),
];
assert!(build_questions(&ambiguous).is_err());
}
#[test]
fn dynamic_pages_resolve_with_key_substitution() {
let files = vec![(
"review/[record].yaml".to_string(),
"name: Record\nalternatives:\n - name: view\n features:\n - name: detail\n resource:\n public: true\n key: \"{record}\"\n source:\n kind: kv\n bucket: applicants\n".to_string(),
)];
let questions = build_questions(&files).unwrap();
let page = resolve_question(&questions, "/review/abc123").unwrap();
assert_eq!(page.id, "/review/abc123");
assert_eq!(
page.alternatives[0].features[0].resource.as_ref().unwrap().key.as_deref(),
Some("abc123")
);
assert!(resolve_question(&questions, "/review").is_none());
// The pattern itself still resolves exactly (it IS an id).
assert!(resolve_question(&questions, "/review/[record]").is_some());
}
#[test]
fn requires_chain_resolves_and_validates() {
let files = vec![
("shape.yaml".to_string(), "name: Shape\n".to_string()),
(
"shaped.yaml".to_string(),
"name: Shaped\nrequires_chain: shape\n".to_string(),
),
];
let questions = build_questions(&files).unwrap();
assert_eq!(questions["/shaped"].requires_chain.as_deref(), Some("/shape"));
assert!(validate_questions(&questions, &Default::default()).is_ok());
let dangling = vec![(
"shaped.yaml".to_string(),
"name: Shaped\nrequires_chain: /nowhere\n".to_string(),
)];
let questions = build_questions(&dangling).unwrap();
assert!(validate_questions(&questions, &Default::default()).is_err());
}
#[test]
fn actions_may_not_target_dynamic_pages() {
let files = vec![
(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: go\n action: /review/[record]\n".to_string(),
),
("review/[record].yaml".to_string(), "name: Record\n".to_string()),
];
let questions = build_questions(&files).unwrap();
assert!(validate_questions(&questions, &Default::default()).is_err());
}
} }
+35 -1
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,
@@ -125,6 +129,10 @@ async fn main() -> anyhow::Result<()> {
let favicon_dark_path = format!("{}/favicon-dark.svg", leptos_options.site_root); let favicon_dark_path = format!("{}/favicon-dark.svg", leptos_options.site_root);
let wordmark_path = format!("{}/wordmark.svg", leptos_options.site_root); let wordmark_path = format!("{}/wordmark.svg", leptos_options.site_root);
let swiper_path = format!("{}/swiper-element-bundle.min.js", leptos_options.site_root); let swiper_path = format!("{}/swiper-element-bundle.min.js", leptos_options.site_root);
// yes.js sits in public/ (not a wasm-bindgen snippet) so the
// hero's inline early-mount script and the wasm binding can share
// one stable URL - see `mod yes` in app.rs.
let yes_path = format!("{}/yes.js", leptos_options.site_root);
let fonts_dir = format!("{}/fonts", leptos_options.site_root); let fonts_dir = format!("{}/fonts", leptos_options.site_root);
let app = Router::new() let app = Router::new()
@@ -135,7 +143,22 @@ async fn main() -> anyhow::Result<()> {
.route("/upload", post(upload::upload)) .route("/upload", post(upload::upload))
.route("/gitea-repo", get(content::gitea_repo_handler)) .route("/gitea-repo", get(content::gitea_repo_handler))
.route("/automation/kv/{bucket}", get(content::automation_kv_handler)) .route("/automation/kv/{bucket}", get(content::automation_kv_handler))
.nest_service("/pkg", ServeDir::new(pkg_dir)) // no-cache = "revalidate before reuse", not "don't cache":
// pkg files keep the same names across releases (portal.js,
// portal.wasm), and without this browsers heuristically cache
// them - a stale wasm from the previous release then talks to
// a server whose server-fn wire format has moved on and every
// page renders as its error branch. A 304 per load is the
// price of never shipping that skew again.
.nest_service(
"/pkg",
tower::ServiceBuilder::new()
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-cache"),
))
.service(ServeDir::new(pkg_dir)),
)
.nest_service("/fonts", ServeDir::new(fonts_dir)) .nest_service("/fonts", ServeDir::new(fonts_dir))
.route_service("/favicon-light.svg", ServeFile::new(favicon_light_path)) .route_service("/favicon-light.svg", ServeFile::new(favicon_light_path))
.route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path)) .route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path))
@@ -144,6 +167,17 @@ async fn main() -> anyhow::Result<()> {
"/swiper-element-bundle.min.js", "/swiper-element-bundle.min.js",
ServeFile::new(swiper_path), ServeFile::new(swiper_path),
) )
// Same skew concern as /pkg: the wasm's raw_module import and
// the hero's inline script both load this by fixed name.
.route_service(
"/yes.js",
tower::ServiceBuilder::new()
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-cache"),
))
.service(ServeFile::new(yes_path)),
)
.leptos_routes_with_context( .leptos_routes_with_context(
&state, &state,
routes, routes,
+90 -23
View File
@@ -16,7 +16,7 @@ use leptos::prelude::*;
/// fetch (`GiteaStarred`/`GiteaOrgRepos`/`Url`), the mechanism behind /// fetch (`GiteaStarred`/`GiteaOrgRepos`/`Url`), the mechanism behind
/// "a resource parameterized by other form fields"; a `Kv` resource /// "a resource parameterized by other form fields"; a `Kv` resource
/// ignores them entirely, same as today. /// ignores them entirely, same as today.
#[server] #[server(endpoint = "get_resource")]
pub async fn get_resource( pub async fn get_resource(
question_id: String, question_id: String,
alternative: String, alternative: String,
@@ -49,7 +49,7 @@ pub async fn get_resource(
/// of whether it's displayed read-only or offered as choices to pick /// of whether it's displayed read-only or offered as choices to pick
/// from, so this deliberately doesn't duplicate the source-dispatch or /// from, so this deliberately doesn't duplicate the source-dispatch or
/// jq-shaping logic - see `fetch_resource_value`. /// jq-shaping logic - see `fetch_resource_value`.
#[server] #[server(endpoint = "get_requirement_options")]
pub async fn get_requirement_options( pub async fn get_requirement_options(
question_id: String, question_id: String,
alternative: String, alternative: String,
@@ -80,7 +80,7 @@ pub async fn get_requirement_options(
/// `get_requirement_options`: fetches the resource a bound field /// `get_requirement_options`: fetches the resource a bound field
/// loads its value from, parameterized by the watched sibling's value /// loads its value from, parameterized by the watched sibling's value
/// (already inside `params`, keyed by the bind's param name). /// (already inside `params`, keyed by the bind's param name).
#[server] #[server(endpoint = "get_requirement_binding")]
pub async fn get_requirement_binding( pub async fn get_requirement_binding(
question_id: String, question_id: String,
alternative: String, alternative: String,
@@ -122,11 +122,12 @@ fn find_feature(
alternative: &str, alternative: &str,
feature_name: &str, feature_name: &str,
) -> Result<crate::content::Feature, ServerFnError> { ) -> Result<crate::content::Feature, ServerFnError> {
let question = state // resolve_question, not a plain map get: a dynamic page's client
.questions // holds its concrete path as the question id, and resolution is
.load() // also what substitutes the URL segment into the page's resource
.get(question_id) // keys - so this lookup is where a `/review/<record>` page's
.cloned() // feature acquires its record-specific key.
let question = crate::content::resolve_question(&state.questions.load(), question_id)
.ok_or_else(|| ServerFnError::new("unknown question"))?; .ok_or_else(|| ServerFnError::new("unknown question"))?;
question question
.alternatives .alternatives
@@ -220,6 +221,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?,
}; };
@@ -442,10 +447,14 @@ mod tests {
use super::*; use super::*;
/// A canned, Gitea-API-shaped fixture - the same fields the real /// A canned, Gitea-API-shaped fixture - the same fields the real
/// `/users/{username}/starred` endpoint returns - run through the /// `/users/{username}/starred` endpoint returns (Gitea 1.27:
/// `stars_count`, not GitHub's `stargazers_count`; `website` is ""
/// when the repo's Website setting is empty) - run through the
/// filter `index.yaml`'s "What we've built" resource actually /// filter `index.yaml`'s "What we've built" resource actually
/// declares, confirming the `jaq` integration produces the shape /// declares, confirming the `jaq` integration produces the shape
/// the frontend showcase card expects. /// the frontend showcase card expects: the Website setting wins
/// over the repo url, and a private repo without one gets a null
/// url (unlinked card) instead of a link that 404s for visitors.
#[test] #[test]
fn jq_shapes_gitea_repo_list_for_the_showcase() { fn jq_shapes_gitea_repo_list_for_the_showcase() {
let input = serde_json::json!([ let input = serde_json::json!([
@@ -453,28 +462,86 @@ mod tests {
"name": "cnats", "name": "cnats",
"description": "A NATS-backed chat client", "description": "A NATS-backed chat client",
"html_url": "https://project.uhhm.no/bl/cnats", "html_url": "https://project.uhhm.no/bl/cnats",
"stargazers_count": 3, "website": "",
"stars_count": 3,
"private": false "private": false
}, },
{ {
"name": "portal", "name": "secret-product",
"description": "This app", "description": "Private repo advertised via its homepage",
"html_url": "https://project.uhhm.no/uhhm/portal", "html_url": "https://project.uhhm.no/uhhm/secret-product",
"stargazers_count": 1, "website": "https://secret-product.example",
"private": false "stars_count": 2,
"private": true
},
{
"name": "internal-tool",
"description": "Private repo with no homepage",
"html_url": "https://project.uhhm.no/uhhm/internal-tool",
"website": "",
"stars_count": 1,
"private": true
} }
]); ]);
let filter = ".[] | {name: .name, description: .description, url: .html_url, stars: .stargazers_count}"; let filter = r#".[] | {name: .name, description: .description,
stars: .stars_count,
url: (if .website != null and .website != "" then .website
elif .private then null
else .html_url end)}"#;
let shaped = apply_jq(filter, &input).expect("filter runs");
let items = shaped.as_array().expect("array output");
assert_eq!(items.len(), 3);
// Public without a website still links to the repo.
assert_eq!(items[0]["name"], "cnats");
assert_eq!(items[0]["url"], "https://project.uhhm.no/bl/cnats");
assert_eq!(items[0]["stars"], 3);
// Private with a website links there, never to the repo.
assert_eq!(items[1]["url"], "https://secret-product.example");
// Private without a website gets no link at all.
assert_eq!(items[2]["url"], serde_json::Value::Null);
// The filter never mentions `private` - confirms shaping
// actually drops fields, not just passes the object through.
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 shaped = apply_jq(filter, &input).expect("filter runs");
let items = shaped.as_array().expect("array output"); let items = shaped.as_array().expect("array output");
assert_eq!(items.len(), 2); assert_eq!(items.len(), 2);
assert_eq!(items[0]["name"], "cnats"); assert_eq!(items[0]["name"], "varde bring-up");
assert_eq!(items[0]["url"], "https://project.uhhm.no/bl/cnats"); assert_eq!(items[0]["tag"], "v0.3.0");
assert_eq!(items[0]["stars"], 3); assert_eq!(items[0]["description"], "Embedded varde-core on iOS.");
// The filter never mentions `private` - confirms shaping assert_eq!(items[0]["url"], serde_json::Value::Null, "no public link while private");
// actually drops fields, not just passes the object through. assert!(items[0].get("html_url").is_none());
assert!(items[0].get("private").is_none());
} }
#[test] #[test]
+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
+233 -52
View File
@@ -47,20 +47,72 @@
--accent-soft: rgba(142, 194, 192, 0.16); --accent-soft: rgba(142, 194, 192, 0.16);
--paper: #0a0a0a; --paper: #0a0a0a;
--paper-raised: #161616;
--ink: #ededed; --ink: #ededed;
--ink-dim: #9a9a9a; --ink-dim: #9a9a9a;
--line: #2a2a2a; --line: #2a2a2a;
/* Cards float over the sticky YES canvas - translucent so the piece
reads faintly through them (paired with backdrop-filter on
.alt-card), shadowed so their edge against the animated backdrop
is a soft lift rather than a hard contrast line. */
--card-bg: rgba(22, 22, 22, 0.35);
--card-bg-opaque: rgba(22, 22, 22, 0.84);
--card-shadow: rgba(0, 0, 0, 0.5);
--error: #ff6b6b;
/* Behind .hero-copy: dark halo on dark stock, paper halo on light. */
--hero-glow: rgba(0, 0, 0, 0.8);
--heading: "Quicksand", var(--sans); --heading: "Quicksand", var(--sans);
--sans: -apple-system, "SF Pro Text", ui-sans-serif, "Segoe UI", system-ui, sans-serif; --sans: -apple-system, "SF Pro Text", ui-sans-serif, "Segoe UI", system-ui, sans-serif;
--radius: 1.1rem; --radius: 1.1rem;
/* The single place absolute units are allowed: this IS the base every
other length derives from (rem/em everywhere else - px is banned in
stylesheets). First line is the fallback for engines without lvmin. */
font-size: 17px; font-size: 17px;
font-size: max(12px, calc(2.1lvmin + 3pt)); font-size: max(12px, calc(2.1lvmin + 3pt));
} }
/* Light theme: the same muted-press idea on white stock - warm paper,
near-black ink, and the accent deepened from dusty cyan to a teal
ink that actually carries as text on light paper (the dark theme's
#8ec2c0 washes out there). yes.js mirrors this palette on its own
(matchMedia in setupTheme) since canvas paint can't read CSS vars. */
@media (prefers-color-scheme: light) {
:root {
--accent: #47807e;
--accent-soft: rgba(71, 128, 126, 0.16);
--paper: #f6f5f1;
--ink: #1d1d1b;
--ink-dim: #6d6c66;
--line: #d9d7cf;
--card-bg: rgba(252, 251, 248, 0.35);
--card-bg-opaque: rgba(252, 251, 248, 0.84);
--card-shadow: rgba(50, 48, 42, 0.18);
--error: #a83a32;
--hero-glow: rgba(246, 245, 241, 0.85);
}
/* The wordmark SVG is a hardcoded white stroke (also used raw in
dark contexts elsewhere) - flip it to ink here rather than fork
the asset. */
.wordmark img {
filter: invert(0.92);
}
/* overlay against near-white paper resolves to ~white and the
raster ghost vanishes; multiply lets the light theme's gray YES
(see rasterizeText's themed repaint in yes.js) show as ink. */
.hero-canvas #rasterCanvas {
mix-blend-mode: multiply;
}
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -112,7 +164,7 @@ main.not-found {
/* hero */ /* hero */
.hero { .hero {
max-width: 780px; max-width: 46rem;
margin: 0 auto; margin: 0 auto;
padding: 4rem 1.5rem 2.5rem; padding: 4rem 1.5rem 2.5rem;
display: flex; display: flex;
@@ -154,7 +206,14 @@ main.not-found {
than interrupting the canvas. */ than interrupting the canvas. */
.hero-yes { .hero-yes {
position: relative; /* Sticky at the viewport top for the whole scroll (its containing
block is the page itself), so the piece stays animating behind
everything that follows - the translucent cards scroll over it
and it shows through them and in the gaps around them. z-index 0
so positioned content below can stack above with z-index 1. */
position: sticky;
top: 0;
z-index: 0;
max-width: none; max-width: none;
width: 100%; width: 100%;
/* svh here is only the pre-JS/no-JS fallback (and first paint before /* svh here is only the pre-JS/no-JS fallback (and first paint before
@@ -200,28 +259,47 @@ main.not-found {
z-index: 1; z-index: 1;
padding: 0 1.5rem 3.5rem; padding: 0 1.5rem 3.5rem;
gap: 0.6rem; gap: 0.6rem;
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.8); text-shadow: 0 0.12em 1.4em var(--hero-glow);
} }
/* Softens the otherwise hard cut where the full-bleed canvas meets the /* The gesture hero (site.yaml hero.kind: gesture - redoal.com's
page background right below it - fades to the same --paper color landing): a tall drawing surface where the visitor's stroke and
over the last few ems instead of stopping dead. Non-interactive so ambient echoes from the relay share the stage. Not sticky like the
it never steals clicks meant for the canvas underneath. */ YES piece - drawing and scrolling fight over the same finger. */
.hero-yes::after { .hero-gesture .hero-draw {
content: ""; position: relative;
position: absolute; width: 100%;
left: 0; max-width: 46rem;
right: 0; /* Same jump-avoidance as .gesture-wrap: the canvas (55svh tall)
bottom: 0; only exists after hydration, so hold its height open from the
height: 8rem; first paint. Plain-vh fallback first, like the canvas itself. */
background: linear-gradient(to bottom, transparent, var(--paper)); min-height: 55vh;
pointer-events: none; min-height: 55svh;
z-index: 1; }
.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
sticky canvas must be a positioned box above the hero's z-index 0,
or the (positioned) hero would paint over it. */
.alternatives { .alternatives {
position: relative;
z-index: 1;
max-width: 75ch; max-width: 75ch;
margin: 0 auto 5rem; margin: 0 auto 5rem;
padding: 0 1.5rem; padding: 0 1.5rem;
@@ -229,7 +307,13 @@ main.not-found {
gap: 1.25rem; gap: 1.25rem;
} }
@media (max-width: 700px) { .question-nav,
.question-responsible {
position: relative;
z-index: 1;
}
@media (max-width: 43.75rem) {
.alternatives { .alternatives {
padding: 0; padding: 0;
gap: 3.5rem; gap: 3.5rem;
@@ -263,7 +347,7 @@ main.not-found {
.question-nav a { .question-nav a {
color: var(--ink-dim); color: var(--ink-dim);
text-decoration: none; text-decoration: none;
border-bottom: 1px solid var(--line); border-bottom: 0.06rem solid var(--line);
} }
.question-nav a:hover { .question-nav a:hover {
@@ -292,10 +376,25 @@ main.not-found {
} }
.alt-card { .alt-card {
background: var(--paper-raised); background: var(--card-bg);
border: 1px solid var(--line); /* Frosted glass over the animated canvas behind: the blur is what
keeps text on a translucent card readable while the piece still
reads through it in color and motion. */
-webkit-backdrop-filter: blur(0.8rem);
backdrop-filter: blur(0.8rem);
border: 0.06rem solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
padding: 1.75rem 1.9rem; padding: 1.75rem 1.9rem;
box-shadow: 0 1.1rem 2.4rem var(--card-shadow);
}
/* Without backdrop blur, 0.55-alpha cards put text straight over the
moving canvas - illegible. Engines lacking the filter get a
near-opaque card instead. */
@supports not ((backdrop-filter: blur(1rem)) or (-webkit-backdrop-filter: blur(1rem))) {
.alt-card {
background: var(--card-bg-opaque);
}
} }
.alt-image { .alt-image {
@@ -363,7 +462,7 @@ main.not-found {
} }
.feature { .feature {
border-left: 3px solid var(--feature-accent, transparent); border-left: 0.18rem solid var(--feature-accent, transparent);
} }
.feature-icon { .feature-icon {
@@ -413,7 +512,7 @@ textarea {
font: inherit; font: inherit;
color: var(--ink); color: var(--ink);
background: var(--paper); background: var(--paper);
border: 1px solid var(--line); border: 0.06rem solid var(--line);
border-radius: 0.6rem; border-radius: 0.6rem;
padding: 0.65rem 0.8rem; padding: 0.65rem 0.8rem;
width: 100%; width: 100%;
@@ -438,21 +537,100 @@ input:focus,
textarea:focus { textarea:focus {
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft); box-shadow: 0 0 0 0.18rem var(--accent-soft);
}
/* One placeholder treatment for every input-like surface: solid
ink-dim, no translucency. opacity: 1 also undoes the UA's own
placeholder dimming (Firefox) and ProseKit's 0.3 on its
placeholder pseudo-element, which read far dimmer than the
native inputs next to it. */
::placeholder,
.prosekit-editor .prosekit-placeholder::before,
.prosekit-editor.prosekit-placeholder::before {
color: var(--ink-dim);
opacity: 1;
} }
.prosekit-wrap { .prosekit-wrap {
background: var(--paper); background: var(--paper);
border: 1px solid var(--line); border: 0.06rem solid var(--line);
border-radius: 0.6rem; border-radius: 0.6rem;
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;
/* The canvas is created by JS only after wasm hydration - reserve
its 3/2 box now so content below doesn't jump when it appears.
aspect-ratio is a preferred size, so the box still grows when the
echo thumbnail strip shows up under the canvas. */
aspect-ratio: 3 / 2;
}
.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;
padding: 0.4rem; padding: 0.4rem;
border-bottom: 1px solid var(--line); border-bottom: 0.06rem solid var(--line);
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -461,7 +639,7 @@ textarea:focus {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--ink-dim); color: var(--ink-dim);
background: transparent; background: transparent;
border: 1px solid transparent; border: 0.06rem solid transparent;
border-radius: 0.4rem; border-radius: 0.4rem;
padding: 0.3rem 0.55rem; padding: 0.3rem 0.55rem;
cursor: pointer; cursor: pointer;
@@ -491,60 +669,63 @@ textarea:focus {
.prosekit-wrap:focus-within { .prosekit-wrap:focus-within {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft); box-shadow: 0 0 0 0.18rem var(--accent-soft);
} }
/* ProseKit's typography.css (loaded from esm.sh at mount time) sets /* The editor's whole type scale, in rem. ProseKit's own typography.css
fixed px font sizes on .ProseMirror, so they ignore the responsive is deliberately not loaded (it sizes content in fixed px - see
root font-size. Re-state them in rem/em at the same proportions, mountEditor in prosekit-editor.js), so these rules are the only
with .prosekit-editor prefixed so these win on specificity. */ typography the editor gets, on top of UA defaults (em-based, they
.prosekit-editor .ProseMirror p { scale fine for lists/blockquote/code). NOTE: editor.mount() turns the
.prosekit-editor div itself into the .ProseMirror root, so content
elements are its direct children - no .ProseMirror in the selector. */
.prosekit-editor p {
font-size: 1rem; font-size: 1rem;
line-height: 1.5; line-height: 1.5;
margin: 0 0 0.6em; margin: 0 0 0.6em;
} }
.prosekit-editor .ProseMirror p:last-child { .prosekit-editor p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
.prosekit-editor .ProseMirror h1 { .prosekit-editor h1 {
font-size: 2.5rem; font-size: 2.5rem;
line-height: 1.1; line-height: 1.1;
margin: 0.9em 0 0.1em; margin: 0.9em 0 0.1em;
} }
.prosekit-editor .ProseMirror h2 { .prosekit-editor h2 {
font-size: 1.875rem; font-size: 1.875rem;
line-height: 1.3; line-height: 1.3;
margin: 1.05em 0 0.13em; margin: 1.05em 0 0.13em;
} }
.prosekit-editor .ProseMirror h3 { .prosekit-editor h3 {
font-size: 1.5rem; font-size: 1.5rem;
line-height: 1.33; line-height: 1.33;
margin: 0.9em 0 0.04em; margin: 0.9em 0 0.04em;
} }
.prosekit-editor .ProseMirror h4 { .prosekit-editor h4 {
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.3; line-height: 1.3;
margin: 0.8em 0 0.05em; margin: 0.8em 0 0.05em;
} }
.prosekit-editor .ProseMirror h5 { .prosekit-editor h5 {
font-size: 1.125rem; font-size: 1.125rem;
line-height: 1.22; line-height: 1.22;
margin: 0.78em 0 0.06em; margin: 0.78em 0 0.06em;
} }
.prosekit-editor .ProseMirror h6 { .prosekit-editor h6 {
font-size: 1rem; font-size: 1rem;
line-height: 1.25; line-height: 1.25;
margin: 0.75em 0 0.06em; margin: 0.75em 0 0.06em;
} }
.prosekit-editor .ProseMirror :is(h1, h2, h3, h4, h5, h6):first-child { .prosekit-editor :is(h1, h2, h3, h4, h5, h6):first-child {
margin-top: 0; margin-top: 0;
} }
@@ -552,7 +733,7 @@ textarea:focus {
font-family: var(--sans); font-family: var(--sans);
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: 0.95rem;
color: #0a0a0a; color: var(--paper);
background: var(--accent); background: var(--accent);
border: none; border: none;
border-radius: 0.7rem; border-radius: 0.7rem;
@@ -566,7 +747,7 @@ textarea:focus {
} }
.alt-submit:active { .alt-submit:active {
transform: translateY(1px); transform: translateY(0.06em);
} }
.alt-submit:disabled { .alt-submit:disabled {
@@ -591,13 +772,13 @@ textarea:focus {
} }
.resource-error { .resource-error {
color: #ff6b6b; color: var(--error);
font-size: 0.9rem; font-size: 0.9rem;
} }
.resource-raw { .resource-raw {
background: var(--paper); background: var(--paper);
border: 1px solid var(--line); border: 0.06rem solid var(--line);
border-radius: 0.6rem; border-radius: 0.6rem;
padding: 0.8rem; padding: 0.8rem;
font-size: 0.82rem; font-size: 0.82rem;
@@ -611,7 +792,7 @@ textarea:focus {
} }
.answer-row { .answer-row {
border: 1px solid var(--line); border: 0.06rem solid var(--line);
border-radius: 0.8rem; border-radius: 0.8rem;
padding: 1rem 1.1rem; padding: 1rem 1.1rem;
display: grid; display: grid;
@@ -624,7 +805,7 @@ textarea:focus {
.answer-fields { .answer-fields {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
gap: 0.5rem 1rem; gap: 0.5rem 1rem;
} }
@@ -674,7 +855,7 @@ textarea:focus {
} }
.item-card { .item-card {
border: 1px solid var(--line); border: 0.06rem solid var(--line);
border-radius: 0.8rem; border-radius: 0.8rem;
padding: 1rem 1.1rem; padding: 1rem 1.1rem;
display: grid; display: grid;
@@ -736,8 +917,8 @@ textarea:focus {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--ink); color: var(--ink);
background: var(--paper); background: var(--paper);
border: 1px solid var(--line); border: 0.06rem solid var(--line);
border-radius: 999px; border-radius: 999rem;
padding: 0.4rem 0.9rem; padding: 0.4rem 0.9rem;
cursor: pointer; cursor: pointer;
transition: border-color 120ms, color 120ms, background 120ms; transition: border-color 120ms, color 120ms, background 120ms;
@@ -759,7 +940,7 @@ textarea:focus {
cursor: not-allowed; cursor: not-allowed;
} }
@media (max-width: 640px) { @media (max-width: 40rem) {
.hero { .hero {
padding: 3rem 1.25rem 2rem; padding: 3rem 1.25rem 2rem;
} }