Compare commits

...
13 Commits
Author SHA1 Message Date
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
17 changed files with 1369 additions and 282 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/
+9
View File
@@ -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}}"
+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(),
+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,
+183 -17
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem}; use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
use crate::auth::{current_user, User}; use crate::auth::{current_user, User};
use crate::content::{is_qualified, Alternative, Question, Responsible, Transition}; use crate::content::{is_qualified, Alternative, Question, Responsible, SiteConfig, Transition};
use crate::resource::{get_requirement_binding, get_requirement_options, get_resource}; use crate::resource::{get_requirement_binding, get_requirement_options, get_resource};
/// The visible site name/wordmark - "portal" is just this codebase's /// The visible site name/wordmark - "portal" is just this codebase's
@@ -83,6 +83,7 @@ fn QuestionPage() -> impl IntoView {
let question = Resource::new(move || path.get(), get_question); let question = Resource::new(move || path.get(), get_question);
let user = Resource::new(|| (), |_| current_user()); let user = Resource::new(|| (), |_| current_user());
let site = Resource::new(|| (), |_| get_site());
view! { view! {
<Suspense fallback=|| { <Suspense fallback=|| {
@@ -99,16 +100,25 @@ fn QuestionPage() -> impl IntoView {
// has already resolved. // has already resolved.
let user_res = user.get(); let user_res = user.get();
let question_res = question.get(); let question_res = question.get();
let site_cfg = site.get().and_then(|r| r.ok()).unwrap_or_default();
question_res question_res
.map(|res| match res { .map(|res| match res {
Ok(Some(q)) => { Ok(Some(q)) => {
let current = user_res.and_then(|r| r.ok()).flatten(); let current = user_res.and_then(|r| r.ok()).flatten();
view! { view! {
// A second <Title> outranks App's
// SITE_NAME fallback only when content
// actually declares one.
{site_cfg
.title
.clone()
.map(|t| view! { <Title text=t/> })}
<QuestionView <QuestionView
question=q question=q
parent_hash=parent_hash.get() parent_hash=parent_hash.get()
query_email=query_email.get() query_email=query_email.get()
user=current user=current
site=site_cfg
/> />
} }
.into_any() .into_any()
@@ -126,6 +136,7 @@ fn QuestionView(
parent_hash: Option<String>, parent_hash: Option<String>,
query_email: Option<String>, query_email: Option<String>,
user: Option<User>, user: Option<User>,
site: SiteConfig,
) -> impl IntoView { ) -> impl IntoView {
let question_id = question.id.clone(); let question_id = question.id.clone();
@@ -138,7 +149,8 @@ fn QuestionView(
<Hero <Hero
title=question.name.clone() title=question.name.clone()
description=question.description.clone() description=question.description.clone()
show_yes=question_id == "/" landing=question_id == "/"
site=site.clone()
/> />
<div class="alternatives"> <div class="alternatives">
<section class="alt-card gate-card"> <section class="alt-card gate-card">
@@ -175,7 +187,8 @@ fn QuestionView(
<Hero <Hero
title=question.name.clone() title=question.name.clone()
description=question.description.clone() description=question.description.clone()
show_yes=question_id == "/" landing=question_id == "/"
site=site.clone()
/> />
<div class="alternatives"> <div class="alternatives">
<For <For
@@ -347,16 +360,57 @@ mod prosekit {
} }
} }
// The `type: gesture` drawing widget (`gesture.js`, crate root):
// prosekit's hidden-input bridge for the value, yes.js's typed-handle
// lifecycle for cleanup - it may own a live WebSocket to a
// redoal-relay, which SPA navigation must close (`stop()`).
#[cfg(feature = "hydrate")]
mod gesture {
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/gesture.js")]
extern "C" {
pub type GestureWidget;
#[wasm_bindgen(js_name = mountGesture)]
pub fn mount_gesture(
container: &web_sys::HtmlDivElement,
hidden: &web_sys::HtmlInputElement,
relay_url: &str,
) -> GestureWidget;
#[wasm_bindgen(js_name = mountGestureHero)]
pub fn mount_gesture_hero(
container: &web_sys::HtmlDivElement,
relay_url: &str,
) -> GestureWidget;
#[wasm_bindgen(method)]
pub fn stop(this: &GestureWidget);
}
}
#[component] #[component]
fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView { fn Hero(title: String, description: String, landing: bool, site: SiteConfig) -> impl IntoView {
// Only the landing page gets the full interactive piece - it's the // Only the landing page gets a full interactive piece - it's the
// one page this is actually "the" hero for; other pages (thank-you // one page a hero is actually "the" hero for; other pages
// pages, /review) get the plain dark header below. Starts the // (thank-you pages, /review) get the plain dark header below.
// animation once the canvas elements exist, and explicitly stops // WHICH piece is the content repo's call (`site.yaml`'s
// the requestAnimationFrame loop on unmount - the original // hero.kind): the YES canvas (default - uhhm's look), a redoal
// page-owning script never needed this since navigating away meant // gesture canvas, or nothing.
// a full document unload, which doesn't happen in an SPA. let hero_kind = if landing { site.hero.kind.clone() } else { "plain".to_string() };
let show_yes = hero_kind == "yes";
let show_gesture = hero_kind == "gesture";
let wordmark = site.wordmark.clone().unwrap_or_else(|| "/wordmark.svg".to_string());
let site_title = site.title.clone().unwrap_or_else(|| SITE_NAME.to_string());
// Starts the YES animation once the canvas elements exist, and
// explicitly stops the requestAnimationFrame loop on unmount - the
// original page-owning script never needed this since navigating
// away meant a full document unload, which doesn't happen in an
// SPA.
let raster_ref: NodeRef<leptos::html::Canvas> = NodeRef::new(); let raster_ref: NodeRef<leptos::html::Canvas> = NodeRef::new();
let gesture_ref: NodeRef<leptos::html::Div> = NodeRef::new();
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
if show_yes { if show_yes {
// `on_cleanup` requires Send + Sync (even single-threaded, wasm), // `on_cleanup` requires Send + Sync (even single-threaded, wasm),
@@ -391,8 +445,35 @@ fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView {
}); });
} }
// The gesture hero mounts the same widget the `type: gesture`
// requirement uses, in hero mode (no form field; echoes render as
// ambient strokes on the canvas itself). Same StoredValue +
// on_cleanup shape as the YES piece - it may own a live WebSocket.
#[cfg(feature = "hydrate")]
if show_gesture {
let relay = site.hero.relay.clone().unwrap_or_default();
let widget: StoredValue<Option<gesture::GestureWidget>, LocalStorage> =
StoredValue::new_local(None);
Effect::new(move |_| {
let Some(container) = gesture_ref.get() else {
return;
};
if widget.with_value(|w| w.is_some()) {
return;
}
widget.set_value(Some(gesture::mount_gesture_hero(&container, &relay)));
});
on_cleanup(move || {
widget.update_value(|opt| {
if let Some(w) = opt.take() {
w.stop();
}
});
});
}
view! { view! {
<header class="hero" class:hero-yes=show_yes> <header class="hero" class:hero-yes=show_yes class:hero-gesture=show_gesture>
{show_yes {show_yes
.then(|| { .then(|| {
view! { view! {
@@ -402,9 +483,13 @@ fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView {
</div> </div>
} }
})} })}
{show_gesture
.then(|| {
view! { <div class="hero-draw" node_ref=gesture_ref></div> }
})}
<div class="hero-copy"> <div class="hero-copy">
<a class="wordmark" href="/"> <a class="wordmark" href="/">
<img src="/wordmark.svg" alt=SITE_NAME/> <img src=wordmark alt=site_title/>
</a> </a>
<h1>{title}</h1> <h1>{title}</h1>
<p>{description}</p> <p>{description}</p>
@@ -536,6 +621,10 @@ fn AlternativeCard(
let mut select_field_map: std::collections::HashMap<String, RwSignal<Vec<String>>> = let mut select_field_map: std::collections::HashMap<String, RwSignal<Vec<String>>> =
std::collections::HashMap::new(); std::collections::HashMap::new();
let mut select_multi: std::collections::HashMap<String, bool> = std::collections::HashMap::new(); let mut select_multi: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
// Gesture fields share field_map (their hidden input carries a
// JSON string), but submit re-parses them so the stored answer
// holds a real {points, key} object, not a double-encoded string.
let mut gesture_fields: std::collections::HashSet<String> = std::collections::HashSet::new();
for feature in &alternative.features { for feature in &alternative.features {
for req in &feature.requirements { for req in &feature.requirements {
if req.kind == "file" { if req.kind == "file" {
@@ -546,6 +635,9 @@ fn AlternativeCard(
.or_insert_with(|| RwSignal::new(Vec::new())); .or_insert_with(|| RwSignal::new(Vec::new()));
select_multi.insert(req.name.clone(), req.multiple); select_multi.insert(req.name.clone(), req.multiple);
} else { } else {
if req.kind == "gesture" {
gesture_fields.insert(req.name.clone());
}
field_map field_map
.entry(req.name.clone()) .entry(req.name.clone())
.or_insert_with(|| RwSignal::new(String::new())); .or_insert_with(|| RwSignal::new(String::new()));
@@ -571,6 +663,7 @@ fn AlternativeCard(
let file_refs_for_submit = file_refs.clone(); let file_refs_for_submit = file_refs.clone();
let select_field_map_for_submit = select_field_map.clone(); let select_field_map_for_submit = select_field_map.clone();
let select_multi_for_submit = select_multi.clone(); let select_multi_for_submit = select_multi.clone();
let gesture_fields_for_submit = gesture_fields.clone();
let question_id_for_transition = question_id.clone(); let question_id_for_transition = question_id.clone();
let alt_name_for_transition = alternative.name.clone(); let alt_name_for_transition = alternative.name.clone();
let on_submit = move |ev: leptos::ev::SubmitEvent| { let on_submit = move |ev: leptos::ev::SubmitEvent| {
@@ -597,13 +690,22 @@ fn AlternativeCard(
let file_refs_for_submit = file_refs_for_submit.clone(); let file_refs_for_submit = file_refs_for_submit.clone();
let select_field_map_for_submit = select_field_map_for_submit.clone(); let select_field_map_for_submit = select_field_map_for_submit.clone();
let select_multi_for_submit = select_multi_for_submit.clone(); let select_multi_for_submit = select_multi_for_submit.clone();
let gesture_fields_for_submit = gesture_fields_for_submit.clone();
let question_id_for_submit = question_id_for_submit.clone(); let question_id_for_submit = question_id_for_submit.clone();
let alt_name_for_submit = alt_name_for_submit.clone(); let alt_name_for_submit = alt_name_for_submit.clone();
let parent_hash_for_submit = parent_hash_for_submit.clone(); let parent_hash_for_submit = parent_hash_for_submit.clone();
leptos::task::spawn_local(async move { leptos::task::spawn_local(async move {
let mut map = serde_json::Map::new(); let mut map = serde_json::Map::new();
for (name, sig) in field_map_for_submit.iter() { for (name, sig) in field_map_for_submit.iter() {
map.insert(name.clone(), serde_json::Value::String(sig.get())); let value = if gesture_fields_for_submit.contains(name) {
// The widget's hidden input holds {points, key} as
// JSON; store the object itself (empty/never-drawn
// becomes null, not "").
serde_json::from_str(&sig.get()).unwrap_or(serde_json::Value::Null)
} else {
serde_json::Value::String(sig.get())
};
map.insert(name.clone(), value);
} }
for (name, sig) in select_field_map_for_submit.iter() { for (name, sig) in select_field_map_for_submit.iter() {
let ids = sig.get(); let ids = sig.get();
@@ -850,6 +952,60 @@ fn AlternativeCard(
.into_any(); .into_any();
} }
if req.kind == "gesture" {
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
// A div, not a label: a label would forward
// every click to the hidden input, and this
// field is drawn on, not clicked into.
#[cfg(feature = "hydrate")]
{
let relay = req.relay.clone().unwrap_or_default();
// Unlike prosekit's fire-and-forget mount,
// the widget may own a live WebSocket -
// yes.js's StoredValue + on_cleanup pattern
// closes it on SPA navigation.
let widget: StoredValue<Option<gesture::GestureWidget>, LocalStorage> =
StoredValue::new_local(None);
Effect::new(move |_| {
let (Some(container), Some(hidden)) =
(container_ref.get(), hidden_ref.get())
else {
return;
};
if widget.with_value(|w| w.is_some()) {
return;
}
widget.set_value(Some(gesture::mount_gesture(
&container, &hidden, &relay,
)));
});
on_cleanup(move || {
widget.update_value(|opt| {
if let Some(w) = opt.take() {
w.stop();
}
});
});
}
return view! {
<div class="field">
{label_text}
<input
id=field_id
type="hidden"
node_ref=hidden_ref
prop:value=move || sig.get()
on:input=move |ev| sig.set(event_target_value(&ev))
/>
<div class="gesture-wrap" node_ref=container_ref></div>
</div>
}
.into_any();
}
if req.kind == "prosekit" { if req.kind == "prosekit" {
let container_ref: NodeRef<leptos::html::Div> = NodeRef::new(); let container_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new(); let hidden_ref: NodeRef<leptos::html::Input> = NodeRef::new();
@@ -1484,18 +1640,28 @@ fn NotFound() -> impl IntoView {
} }
} }
#[server] #[server(endpoint = "get_question")]
pub async fn get_question(path: String) -> Result<Option<Question>, ServerFnError> { pub async fn get_question(path: String) -> Result<Option<Question>, ServerFnError> {
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()) Ok(state.questions.load().get(&path).cloned())
} }
/// The content repo's branding (`site.yaml`) - title, wordmark, hero
/// kind. Defaults (= the historical uhhm look) when the repo declares
/// nothing.
#[server(endpoint = "get_site")]
pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
Ok(state.site.load().as_ref().clone())
}
/// Every question the current visitor qualifies for, as (id, name) - /// Every question the current visitor qualifies for, as (id, name) -
/// the site nav's data. Context-dependent: an owner session sees the /// the site nav's data. Context-dependent: an owner session sees the
/// gated pages too, and `followup` pages only appear once the visitor /// gated pages too, and `followup` pages only appear once the visitor
/// 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> {
@@ -1528,7 +1694,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,
+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
+26 -3
View File
@@ -32,12 +32,15 @@ async fn main() -> anyhow::Result<()> {
} }
} }
let (questions, aggregates_map) = if let Some(dir) = path { let (questions, aggregates_map, site) = if let Some(dir) = path {
(load_from_dir(&dir)?, load_aggregates_from_dir(&dir)?) (load_from_dir(&dir)?, load_aggregates_from_dir(&dir)?, load_site_from_dir(&dir)?)
} else if let Some(repo_url) = repo { } else if let Some(repo_url) = repo {
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?; let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?;
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?; let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?;
(questions, aggregates_map) // load_site_from_gitea already validates; a missing file is
// the default config, same as at portal boot.
let site = content::load_site_from_gitea(&repo_url, &branch).await?;
(questions, aggregates_map, site)
} else { } else {
eprintln!( eprintln!(
"usage: question-lint --repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>" "usage: question-lint --repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>"
@@ -45,6 +48,10 @@ async fn main() -> anyhow::Result<()> {
std::process::exit(2); std::process::exit(2);
}; };
if let Err(e) = site.validate() {
eprintln!("FAIL: {e}");
std::process::exit(1);
}
match content::validate_questions(&questions, &aggregates_map) { match content::validate_questions(&questions, &aggregates_map) {
Ok(()) => { Ok(()) => {
println!( println!(
@@ -61,6 +68,22 @@ async fn main() -> anyhow::Result<()> {
} }
} }
/// The offline counterpart to `content::load_site_from_gitea` -
/// `site.yaml` lives at the repo root like `aggregates.yaml`, and is
/// just as optional locally: missing means default branding.
fn load_site_from_dir(dir: &str) -> anyhow::Result<content::SiteConfig> {
let site_path = std::path::Path::new(dir)
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("site.yaml");
if !site_path.exists() {
return Ok(content::SiteConfig::default());
}
let raw = std::fs::read_to_string(&site_path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", site_path.display()))?;
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display()))
}
/// The offline counterpart to `content::load_aggregates_from_gitea` - /// The offline counterpart to `content::load_aggregates_from_gitea` -
/// `aggregates.yaml` lives at the repo root, one level up from the /// `aggregates.yaml` lives at the repo root, one level up from the
/// pages directory `--path` names, so `dir`'s parent is where it's /// pages directory `--path` names, so `dir`'s parent is where it's
+220
View File
@@ -163,6 +163,11 @@ pub enum ResourceSource {
Kv { bucket: String }, Kv { bucket: String },
GiteaStarred { username: String }, GiteaStarred { username: String },
GiteaOrgRepos { org: String }, GiteaOrgRepos { org: String },
/// A repo's releases - fetched with `GITEA_API_TOKEN`, so it works
/// for private repos too (shape the link with jq: a private
/// release's html_url 404s for anonymous visitors, so map it to
/// null unless the repo is public).
GiteaReleases { owner: String, repo: String },
/// Any other HTTPS JSON endpoint. Scheme-restricted and checked /// Any other HTTPS JSON endpoint. Scheme-restricted and checked
/// against loopback/private/link-local addresses at fetch time /// against loopback/private/link-local addresses at fetch time
/// (`resource::fetch_url_resource`) - a server-side fetch of a /// (`resource::fetch_url_resource`) - a server-side fetch of a
@@ -178,6 +183,7 @@ impl ResourceSpec {
ResourceSource::Kv { bucket } => Some(bucket), ResourceSource::Kv { bucket } => Some(bucket),
ResourceSource::GiteaStarred { .. } ResourceSource::GiteaStarred { .. }
| ResourceSource::GiteaOrgRepos { .. } | ResourceSource::GiteaOrgRepos { .. }
| ResourceSource::GiteaReleases { .. }
| ResourceSource::Url { .. } => None, | ResourceSource::Url { .. } => None,
} }
} }
@@ -231,6 +237,13 @@ pub struct Requirement {
/// the selected file's current content. /// the selected file's current content.
#[serde(default)] #[serde(default)]
pub bind: Option<Bind>, pub bind: Option<Bind>,
/// `type: gesture` only - ws(s):// URL of a redoal-relay instance
/// the drawing widget connects to for live echoes of similar
/// strokes. Absent means the widget works offline: the drawn path
/// still submits, it just never gets a network-computed key or
/// echoes.
#[serde(default)]
pub relay: Option<String>,
} }
/// A field's live data source, parameterized by a sibling field's /// A field's live data source, parameterized by a sibling field's
@@ -265,6 +278,104 @@ impl Requirement {
} }
} }
/// Site-wide branding declared by the content repo - `site.yaml` at
/// the repo root, sibling of `aggregates.yaml`. An absent file means
/// all defaults, which is exactly the historical uhhm look: existing
/// deployments change nothing without a content edit. Everything the
/// browser needs, so it travels through a server fn (`get_site`).
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct SiteConfig {
/// Browser-tab title and wordmark alt text. `None` falls back to
/// the compile-time `SITE_NAME`.
#[serde(default)]
pub title: Option<String>,
/// Wordmark image URL (absolute or a path this instance serves).
/// `None` falls back to `/wordmark.svg`.
#[serde(default)]
pub wordmark: Option<String>,
#[serde(default)]
pub hero: HeroConfig,
}
/// What the landing page's hero is: the YES canvas piece (`yes`, the
/// default), a redoal gesture-drawing canvas (`gesture`), or copy
/// only (`plain`).
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct HeroConfig {
#[serde(default = "default_hero_kind")]
pub kind: String,
/// `kind: gesture` only - ws(s):// URL of a redoal-relay for
/// ambient echoes. Absent means the hero draws offline.
#[serde(default)]
pub relay: Option<String>,
}
impl Default for HeroConfig {
fn default() -> Self {
Self { kind: default_hero_kind(), relay: None }
}
}
fn default_hero_kind() -> String {
"yes".to_string()
}
#[cfg(feature = "ssr")]
impl SiteConfig {
/// Shared by `load_site_from_gitea` and the `question-lint` binary
/// so a typo'd hero kind is a caught rejection, not a silently
/// plain hero.
pub fn validate(&self) -> anyhow::Result<()> {
if !matches!(self.hero.kind.as_str(), "yes" | "gesture" | "plain") {
anyhow::bail!(
"site.yaml: hero.kind {:?} is not one of yes | gesture | plain",
self.hero.kind
);
}
if let Some(relay) = &self.hero.relay {
if self.hero.kind != "gesture" {
anyhow::bail!("site.yaml: hero.relay only makes sense with hero.kind: gesture");
}
validate_relay_url(relay).map_err(|e| anyhow::anyhow!("site.yaml: {e}"))?;
}
Ok(())
}
}
/// A relay must be a ws:// or wss:// URL - shared between site.yaml's
/// hero and `Requirement.relay` validation.
#[cfg(feature = "ssr")]
pub fn validate_relay_url(relay: &str) -> anyhow::Result<()> {
let parsed = url::Url::parse(relay)
.map_err(|e| anyhow::anyhow!("relay {relay:?} is not a valid URL: {e}"))?;
if !matches!(parsed.scheme(), "ws" | "wss") {
anyhow::bail!("relay {relay:?} must use the ws:// or wss:// scheme");
}
Ok(())
}
/// Fetches and parses `site.yaml` from the content repo root. A fetch
/// failure (typically 404 - the file is optional) yields the default
/// config; a file that exists but doesn't parse or validate is a real
/// error, surfaced at boot rather than papered over.
#[cfg(feature = "ssr")]
pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result<SiteConfig> {
let (owner, repo) = parse_owner_repo(repo_url)?;
let api_base = gitea_api_base(repo_url)?;
let client = openidconnect::reqwest::Client::new();
let raw = match fetch_gitea_file(&client, &api_base, &owner, &repo, branch, "site.yaml").await {
Ok(raw) => raw,
Err(e) => {
tracing::info!("no site.yaml in content repo ({e}), using default branding");
return Ok(SiteConfig::default());
}
};
let site: SiteConfig =
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
site.validate()?;
Ok(site)
}
/// Extracts `scheme://host` from a repo's normal browser URL - the /// Extracts `scheme://host` from a repo's normal browser URL - the
/// Gitea API base every helper in this module builds requests against. /// Gitea API base every helper in this module builds requests against.
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
@@ -472,6 +583,26 @@ pub fn validate_questions(
question.id, alternative.name, feature.name, requirement.name question.id, alternative.name, feature.name, requirement.name
); );
} }
if let Some(relay) = &requirement.relay {
if requirement.kind != "gesture" {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture",
question.id, alternative.name, feature.name, requirement.name, requirement.kind
);
}
validate_relay_url(relay).map_err(|e| anyhow::anyhow!(
"question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}",
question.id, alternative.name, feature.name, requirement.name
))?;
}
if requirement.kind == "gesture"
&& (requirement.resource.is_some() || requirement.bind.is_some())
{
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: gesture - it draws its value, it can't also load one from a resource or bind",
question.id, alternative.name, feature.name, requirement.name
);
}
if let Some(bind) = &requirement.bind { if let Some(bind) = &requirement.bind {
if bind.field == requirement.name if bind.field == requirement.name
|| !sibling_names.contains(bind.field.as_str()) || !sibling_names.contains(bind.field.as_str())
@@ -543,6 +674,7 @@ pub async fn watch_for_reload(
aggregates: std::sync::Arc< aggregates: std::sync::Arc<
arc_swap::ArcSwap<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>, arc_swap::ArcSwap<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>,
>, >,
site: std::sync::Arc<arc_swap::ArcSwap<SiteConfig>>,
) { ) {
let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await { let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await {
Ok(sub) => sub, Ok(sub) => sub,
@@ -560,6 +692,17 @@ pub async fn watch_for_reload(
continue; continue;
} }
}; };
// Branding swaps with the same all-or-nothing rule: a
// present-but-broken site.yaml keeps last-good everything
// (load_site distinguishes "absent" - a normal default - from
// "present but invalid").
let loaded_site = match load_site_from_gitea(&repo_url, &branch).await {
Ok(loaded) => loaded,
Err(e) => {
tracing::error!(error = %e, "site.yaml reload failed, keeping last-good content");
continue;
}
};
match load_questions_from_gitea(&repo_url, &branch, &subdir).await { match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
Ok(loaded) => { Ok(loaded) => {
if let Err(e) = validate_questions(&loaded, &loaded_aggregates) { if let Err(e) = validate_questions(&loaded, &loaded_aggregates) {
@@ -569,6 +712,7 @@ pub async fn watch_for_reload(
let count = loaded.len(); let count = loaded.len();
questions.store(std::sync::Arc::new(loaded)); questions.store(std::sync::Arc::new(loaded));
aggregates.store(std::sync::Arc::new(loaded_aggregates)); aggregates.store(std::sync::Arc::new(loaded_aggregates));
site.store(std::sync::Arc::new(loaded_site));
tracing::info!(count, "reloaded content"); tracing::info!(count, "reloaded content");
} }
Err(e) => { Err(e) => {
@@ -849,4 +993,80 @@ alternatives:
let err = validate_questions(&question_with_bind("body"), &Default::default()).unwrap_err(); let err = validate_questions(&question_with_bind("body"), &Default::default()).unwrap_err();
assert!(err.to_string().contains("not another requirement")); assert!(err.to_string().contains("not another requirement"));
} }
fn question_with_requirement(req_yaml: &str) -> std::collections::HashMap<String, Question> {
let question: Question = serde_yaml::from_str(&format!(
"id: /g\nname: G\nalternatives:\n - name: A\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
))
.unwrap();
std::collections::HashMap::from([(question.id.clone(), question)])
}
#[test]
fn gesture_with_wss_relay_passes() {
let questions = question_with_requirement(
" - { name: curve, type: gesture, relay: \"wss://relay.redoal.com\", optional: true }",
);
assert!(validate_questions(&questions, &Default::default()).is_ok());
}
#[test]
fn gesture_without_relay_passes_offline() {
let questions =
question_with_requirement(" - { name: curve, type: gesture, optional: true }");
assert!(validate_questions(&questions, &Default::default()).is_ok());
}
#[test]
fn relay_on_non_gesture_kind_is_rejected() {
let questions = question_with_requirement(
" - { name: email, type: email, relay: \"wss://relay.redoal.com\" }",
);
let err = validate_questions(&questions, &Default::default()).unwrap_err();
assert!(err.to_string().contains("relay only makes sense on type: gesture"));
}
#[test]
fn https_relay_is_rejected() {
let questions = question_with_requirement(
" - { name: curve, type: gesture, relay: \"https://relay.redoal.com\" }",
);
let err = validate_questions(&questions, &Default::default()).unwrap_err();
assert!(err.to_string().contains("ws:// or wss://"));
}
#[test]
fn site_config_defaults_to_the_yes_hero() {
let site = SiteConfig::default();
assert_eq!(site.hero.kind, "yes");
assert!(site.validate().is_ok());
// And an empty file parses to the same thing.
let parsed: SiteConfig = serde_yaml::from_str("{}").unwrap();
assert_eq!(parsed, site);
}
#[test]
fn site_config_rejects_unknown_hero_kind() {
let site: SiteConfig = serde_yaml::from_str("hero: { kind: fireworks }").unwrap();
let err = site.validate().unwrap_err();
assert!(err.to_string().contains("fireworks"));
}
#[test]
fn site_config_rejects_relay_without_gesture_hero() {
let site: SiteConfig =
serde_yaml::from_str("hero: { kind: yes, relay: \"wss://relay.redoal.com\" }").unwrap();
assert!(site.validate().is_err());
}
#[test]
fn redoal_site_yaml_shape_parses() {
let site: SiteConfig = serde_yaml::from_str(
"title: redoal\nwordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg\nhero:\n kind: gesture\n relay: wss://relay.redoal.com\n",
)
.unwrap();
assert!(site.validate().is_ok());
assert_eq!(site.title.as_deref(), Some("redoal"));
assert_eq!(site.hero.kind, "gesture");
}
} }
+4
View File
@@ -32,9 +32,11 @@ async fn main() -> anyhow::Result<()> {
let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?; let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?;
let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?; let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
content::validate_questions(&questions, &aggregates)?; content::validate_questions(&questions, &aggregates)?;
let site = content::load_site_from_gitea(&content_repo, &content_branch).await?;
tracing::info!(count = questions.len(), repo = %content_repo, branch = %content_branch, "loaded content"); tracing::info!(count = questions.len(), repo = %content_repo, branch = %content_branch, "loaded content");
let questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions)); let questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions));
let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates)); let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates));
let site = Arc::new(arc_swap::ArcSwap::from_pointee(site));
let nats_url = let nats_url =
std::env::var("NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string()); std::env::var("NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string());
@@ -69,6 +71,7 @@ async fn main() -> anyhow::Result<()> {
"questions".to_string(), "questions".to_string(),
questions.clone(), questions.clone(),
aggregates.clone(), aggregates.clone(),
site.clone(),
)); ));
let state = AppState { let state = AppState {
@@ -77,6 +80,7 @@ async fn main() -> anyhow::Result<()> {
jetstream, jetstream,
questions, questions,
aggregates, aggregates,
site,
gitea_base, gitea_base,
oidc: oidc_state, oidc: oidc_state,
garage, garage,
+84 -18
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,
@@ -220,6 +220,10 @@ async fn fetch_resource_value(
ResourceSource::GiteaOrgRepos { org } => { ResourceSource::GiteaOrgRepos { org } => {
fetch_gitea_json(state, &format!("/api/v1/orgs/{org}/repos"), params).await? fetch_gitea_json(state, &format!("/api/v1/orgs/{org}/repos"), params).await?
} }
ResourceSource::GiteaReleases { owner, repo } => {
fetch_gitea_json(state, &format!("/api/v1/repos/{owner}/{repo}/releases"), params)
.await?
}
ResourceSource::Url { url } => fetch_url_json(url, params).await?, ResourceSource::Url { url } => fetch_url_json(url, params).await?,
}; };
@@ -442,10 +446,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 +461,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
+223 -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,42 @@ 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; }
bottom: 0;
height: 8rem; .hero-gesture .gesture-canvas {
background: linear-gradient(to bottom, transparent, var(--paper)); aspect-ratio: auto;
pointer-events: none; /* Plain-vh fallback first, same reasoning as .hero-yes's height. */
z-index: 1; 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 +302,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 +342,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 +371,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 +457,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 +507,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 +532,95 @@ 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;
}
.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 +629,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 +659,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 +723,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 +737,7 @@ textarea:focus {
} }
.alt-submit:active { .alt-submit:active {
transform: translateY(1px); transform: translateY(0.06em);
} }
.alt-submit:disabled { .alt-submit:disabled {
@@ -591,13 +762,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 +782,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 +795,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 +845,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 +907,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 +930,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;
} }
+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';