Compare commits

..
23 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug 47d7f9d2c3 Release 0.3.5
Publish release / publish (push) Successful in 1m27s
Test / test (push) Successful in 22s
2026-08-30 11:40:47 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 12e9fcd609 Gesture widget: ghost path back, for v2 keys only
Test / test (push) Successful in 23s
The relay's ack path is drawn under the stroke when the key's version
nibble is >= 2 (ADR-0014's ordered turning chain decodes to the
stroke itself); a v1 key's blob stays hidden. Safe to ship ahead of
the relay: nothing shows until the relay encodes v2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 11:38:48 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 72674afe05 Gesture widget: stop drawing the decoded ghost path
Test / test (push) Successful in 25s
decode_key's reconstruction is too rough to have value on screen;
the ack's key is kept, its path ignored. Revisit only once the
reconstruction is fixed at the core in redoal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 11:21:20 +02:00
Bendik Aagaard Lynghaug 7ba9b08d76 Release 0.3.4
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m27s
2026-08-25 20:25:52 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 1a45839699 Alternative.disabled: announced but not yet takeable
Test / test (push) Successful in 24s
The button renders disabled (title 'Not yet') and submit_answer
refuses the alternative server-side - lysbue's disabled flag, back as
content, for advertising a path before it works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:25:49 +02:00
Bendik Aagaard Lynghaug be54f54804 Release 0.3.3
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m26s
2026-08-25 20:09:00 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 8212477377 Empty list says so before it can parse as zero answers
Test / test (push) Successful in 24s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:08:59 +02:00
Bendik Aagaard Lynghaug e97470c2f6 Release 0.3.2
Publish release / publish (push) Successful in 1m35s
Test / test (push) Successful in 40s
2026-08-25 20:04:38 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 9f26203ed5 Resource empty text: content-declared, and null counts as empty
Test / test (push) Successful in 25s
A `.[] | {...}` jq over an empty list yields no output, which arrived
as null and rendered nothing - redoal's Releases feature was a bare
heading. Null now reads as the same silence as [], and ResourceSpec
gains `empty:` so content can word it ("Nothing released yet");
default stays "Nothing here yet."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:04:35 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 d6fdcd0bce README: hashed pkg assets in the release flow
Test / test (push) Successful in 23s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:52:31 +02:00
Bendik Aagaard Lynghaug d65c5593b3 Release 0.3.1
Test / test (push) Successful in 25s
Publish release / publish (push) Successful in 1m25s
2026-08-25 19:48:49 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 330ab11fe9 Content-hashed pkg assets (hash-files) so stale bundles can't pair with new wasm
Test / test (push) Successful in 26s
An iPhone kept a heuristically-cached portal.js across three releases
(cached before Cache-Control: no-cache existed) and loaded it against
fresh wasm - its snippet imports 404'd, hydration never started, and
the gesture field never mounted. With hash-files = true every pkg
file is content-named and the freshly served page references exactly
its own bundle; hash.txt ships beside the binary (leptos resolves it
next to current_exe), the shell links the stylesheet through
HashedStylesheet, and instances set LEPTOS_HASH_FILES=true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:48:47 +02:00
Bendik Aagaard Lynghaug d403eda5da Release 0.3.0
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m5s
2026-08-25 17:26:31 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 1afd34c22e Hero becomes a content-owned module; site asset proxy; gesture growth fix
Test / test (push) Successful in 24s
site.yaml's hero is now plain | module, where module names a
JavaScript file the content repo ships (mount(container) -> handle
with stop()). Portal serves content assets same-origin at
/site/<path> (Gitea raw sends no CORS headers), starts the module at
HTML parse time, adopts it on hydration, mounts fresh via the inline
script's __mountHero on client-side navigation, and stops it on
leave. The YES canvas (yes.js) and the gesture hero mode leave the
engine - uhhm/questions ships YES as its hero.js, redoal/questions
ships a sine-swings band. Gesture form canvas no longer balloons after
a stroke: the wrap's aspect-ratio reservation is scoped to :empty
(pre-mount) so it can't turn echo-strip height into width inside the
flex field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 17:26:29 +02:00
Bendik Aagaard Lynghaug 307fd7e753 Release 0.2.4
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m16s
2026-08-25 17:05:12 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 30c2b6bea9 Gates keep the question nav
Test / test (push) Successful in 24s
The qualifies and requires_chain gates replaced the whole page,
footer nav included - a visitor hitting one had only the wordmark as
a way out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 17:05:09 +02:00
Bendik Aagaard Lynghaug b875045bbc Release 0.2.3
Test / test (push) Successful in 24s
Publish release / publish (push) Successful in 1m8s
2026-08-25 15:36:10 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 a1fc333079 Attended-bucket lint rule; app-lifetime resources fix first-nav corruption
Test / test (push) Successful in 25s
validate_questions now rejects content where a record_as bucket is
read by nothing: every bucket must be listed by some Kv resource in
the same repo (a desk) or carry aggregates.yaml's new attended_by
annotation naming the automation that consumes it - no publicly
collected answer may land where nothing reads.

Separately, all server-fn resources (question/user/nav) move to a
PortalShell above the routes, created once and provided via context.
Per-page Resources broke on the first client-side navigation: the
remounted component's fresh Resource consumed a stale SSR hydration
buffer - the nav list [[id, name], ..] deserialized as a Page (serde
fills structs from sequences in field order), so uhhm.no's landing
question rendered chain-gated behind its own nav entry instead of
the /develop/proposal form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:36:07 +02:00
Bendik Aagaard Lynghaug 0d6cdb8a00 Release 0.2.2
Test / test (push) Successful in 23s
Publish release / publish (push) Successful in 1m5s
2026-08-24 22:47:12 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b2342f074b One app-level Title fed by a shared site resource
Test / test (push) Successful in 24s
The per-page override remounted on every SPA navigation and lost the
leptos_meta race to App's static SITE_NAME fallback regardless of
hoisting - single source of truth instead: App owns the site resource
(provided via context, pages reuse it) and the only Title, wrapped in
Suspense so SSR still serves the resolved name.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:40:29 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c67f6f1a59 Docs: routing bullet + design doc marked implemented
Test / test (push) Successful in 23s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:33:37 +02:00
13 changed files with 511 additions and 916 deletions
+4 -1
View File
@@ -68,11 +68,14 @@ jobs:
stage=$(mktemp -d)
cp "$CARGO_TARGET_DIR/release/portal" "$stage/portal"
cp "$CARGO_TARGET_DIR/release/question_lint" "$stage/question_lint"
# hash-files = true: leptos resolves hash.txt next to the running
# binary, so it ships beside portal in the release dir.
cp "$CARGO_TARGET_DIR/release/hash.txt" "$stage/hash.txt"
# 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
tar -C "$stage" -czf "portal-$tag.tar.gz" portal question_lint hash.txt site
rm -rf "$stage"
# The run's own ephemeral token has write access to this repo -
Generated
+1 -1
View File
@@ -2948,7 +2948,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.2.0"
version = "0.3.5"
dependencies = [
"anyhow",
"arc-swap",
+8 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.2.0"
version = "0.3.5"
edition = "2021"
[lib]
@@ -19,7 +19,7 @@ axum = { version = "0.8", features = ["multipart"], optional = true }
aws-sdk-s3 = { version = "1", optional = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"], optional = true }
tower = { version = "0.5", optional = true }
tower-http = { version = "0.6", features = ["fs"], optional = true }
tower-http = { version = "0.6", features = ["fs", "set-header"], optional = true }
tower-sessions = { version = "0.14", optional = true }
async-nats = { version = "0.38", optional = true }
arc-swap = { version = "1", optional = true }
@@ -131,6 +131,12 @@ bin-default-features = false
lib-features = ["hydrate"]
lib-default-features = false
lib-profile-release = "wasm-release"
# Content-hashed pkg names (portal.<hash>.js/wasm/css) + hash.txt: a
# freshly served page can never reference a stale cached bundle - an
# iPhone kept a heuristically-cached portal.js across three releases
# and paired it with new wasm, silently killing hydration. Runtime
# needs LEPTOS_HASH_FILES=true (set in each content repo's deploy env).
hash-files = true
# cargo release <level> is how a portal version is cut: bump, commit,
# tag v{{version}}, push. CI (publish.yml) reacts to the tag and
+17 -6
View File
@@ -8,9 +8,12 @@ serves is declared in its content repo, and the same build serves any
number of sites.
Point an instance at a content repo and that repo *is* the site:
pages, copy, state graphs, and branding (`site.yaml`: title,
wordmark, and which hero the landing page opens on — the YES canvas,
a gesture-drawing canvas wired to a relay, or plain copy). Content
pages, copy, state graphs, branding, and even the landing hero
(`site.yaml`: title, wordmark, and `hero: {kind: module, module:
hero.js}` — a JavaScript module the content repo ships, served
same-origin at `/site/<path>`, that portal mounts at parse time and
stops on navigation; the YES canvas and redoal's sine swings are both
content, not engine). Content
pushes hot-reload every running instance; instances differ only by
env vars. [uhhm.no](https://uhhm.no)
([uhhm/questions](https://project.uhhm.no/uhhm/questions)) and
@@ -29,8 +32,13 @@ stream.
- **Content-driven pages** (`src/content.rs`, `src/app.rs`): YAML
loaded from Gitea at boot and hot-swapped on a NATS reload signal;
a bad push keeps the last-good content serving. A page's `id` is
its URL; `qualifies` gates it to a Kanidm group.
a bad push keeps the last-good content serving. The `questions/`
tree IS the router — file paths become URLs, `_section.yaml`
applies criteria to a whole directory, `[name].yaml` pages serve
any `/dir/<value>` with the segment fed into resource keys, and
`requires_chain` gates a page on verifiable answer provenance next
to `qualifies`' Kanidm-group identity gate (see
`docs/design/filesystem-routes.md`).
- **State machines as content** (`src/aggregates/`): `aggregates.yaml`
declares each bucket's states and legal transitions; the engine
replays a record's event history and refuses undeclared moves, with
@@ -97,7 +105,10 @@ cargo release patch # or minor / major
That bumps `Cargo.toml`, tags `v<version>`, and pushes; CI
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
attaches `portal-v<version>.tar.gz` (`portal` + `question_lint` +
`site/`) to the Gitea release. Plain pushes to `main` only run the
`hash.txt` + `site/`) to the Gitea release. Pkg files are
content-hashed (`hash-files = true`; instances run with
`LEPTOS_HASH_FILES=true`), so a freshly served page can never pair a
stale cached bundle with new wasm. Plain pushes to `main` only run the
tests (`test.yml`) — nothing reaches production from this repo.
**Roll it out** (in a content repo): edit one line in that repo's
+7 -1
View File
@@ -1,6 +1,12 @@
# Filesystem routes, sections, and where chains fit
Status: proposal (researched 2026-08-24, nothing implemented).
Status: implemented in v0.2.0 (2026-08-24) — all three phases, with
one deviation: dynamic-page params substitute into resource keys via
server-side `resolve_question` at lookup time (get_question,
find_feature, submit_answer all resolve concrete paths), so no param
threading exists client-side. The user-facing routing contract is
documented in uhhm/questions' README ("Routing: the tree is the
router"); this file stays as the design rationale.
## What exists today, precisely
+18 -36
View File
@@ -12,8 +12,10 @@
//
// 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
// an ack carrying the stroke's gesture key plus the key's own decoded
// path - "what the network heard", drawn as a ghost under the stroke
// when the key is v2 or later (ADR-0014's ordered turning chain; a v1
// key's histogram decode is a blob and stays hidden) - 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.
@@ -26,13 +28,13 @@
const THEMES = {
dark: {
stroke: '#8ec2c0',
ghost: 'rgba(255, 255, 255, 0.28)',
echo: '#8ec2c0',
ghost: 'rgba(255, 255, 255, 0.28)',
},
light: {
stroke: '#47807e',
ghost: 'rgba(29, 29, 27, 0.30)',
echo: '#47807e',
ghost: 'rgba(29, 29, 27, 0.30)',
},
};
@@ -42,14 +44,12 @@ const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000;
class GestureWidget {
constructor(container, hidden, relayUrl, hero) {
constructor(container, hidden, relayUrl) {
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.points = [];
this.drawing = false;
this.stopped = false;
this.ws = null;
@@ -60,11 +60,9 @@ class GestureWidget {
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);
}
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';
@@ -115,11 +113,10 @@ class GestureWidget {
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).
// (and any ghost from the previous round).
this.drawing = true;
this.points = [this.pos(e)];
this.ghost = null;
this.ambient = [];
this.points = [this.pos(e)];
this.render();
}
@@ -220,16 +217,12 @@ class GestureWidget {
if (this.points.length >= 2) {
this.setValue({ points: this.normalized(), key: msg.key });
}
this.ghost = msg.path;
// Version nibble is the key's first hex digit.
const version = parseInt((msg.key || '0')[0], 16);
this.ghost = version >= 2 && Array.isArray(msg.path) ? msg.path : null;
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);
}
this.addEchoThumbnail(msg);
}
// 'error' frames are intentionally silent: the widget is a
// form field first, and a rate-limited announce shouldn't
@@ -284,11 +277,6 @@ class GestureWidget {
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);
}
@@ -325,11 +313,5 @@ class GestureWidget {
}
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);
return new GestureWidget(container, hidden, relayUrl);
}
-578
View File
@@ -1,578 +0,0 @@
// Ported from ~/repos/webpage/content/visualize/ah.html ("YES - Rasterized
// Lines"), the piece currently live at uhhm.no - kept as the actual asset,
// not reinvented. Two changes from the original: exported as a class (no
// auto-init on `window load`, since Leptos controls when this mounts) and
// a `stop()` method that actually breaks the requestAnimationFrame loop -
// the original ran forever once started, fine for a static page that's
// the whole document, not fine in an SPA where this hero mounts/unmounts
// as you navigate.
export class RasterizedYES {
constructor() {
this.rasterCanvas = document.getElementById('rasterCanvas');
this.lineCanvas = document.getElementById('lineCanvas');
// The Rust side only constructs this once it's confirmed (via a
// NodeRef) that the canvas is mounted, but that guard has shown
// a real gap on fast client-side re-navigation back to `/` -
// this is the actual failure point, so it gets its own defense
// rather than depending on getting that timing exactly right
// from the other side of the wasm boundary. Leaving the
// instance otherwise-inert (no crash, no animation) rather than
// throwing mid-render - `stop()` already tolerates a partially
// (non-)initialized instance.
if (!this.rasterCanvas || !this.lineCanvas) {
console.warn('RasterizedYES: canvas not in DOM yet, skipping');
this.destroyed = true;
return;
}
this.rasterCtx = this.rasterCanvas.getContext('2d');
this.lineCtx = this.lineCanvas.getContext('2d');
this.lines = [];
this.rasterData = null;
this.isActive = true;
this.destroyed = false;
this.time = 0;
this.containmentStrength = 0.5;
this.wiggleAmount = 0.5;
// .hero-canvas is inset:0 inside this - freezing an inline
// height here (below) is what actually locks the box, not
// just reading its rect.
this.heroEl = this.rasterCanvas.closest('.hero');
this.setupCanvas();
this.setupResizeHandler();
this.setupDrift();
this.setupTheme();
this.setupScrollFade();
this.setupClickHandler();
this.rasterizeText();
this.initializeLines();
this.animate();
}
stop() {
this.destroyed = true;
if (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)
// only helps if that rect actually stays put - and on real mobile
// Safari it didn't: .hero-copy (bottom-aligned via flexbox inside
// .hero-yes) kept sliding down as the address bar collapsed, even
// with a spec'd-stable viewport unit (svh, then lvh) driving the
// box's height. Either the browser isn't holding up its end, or
// something in the cascade is still landing on a live value - not
// provable from here without the device. Rather than keep
// guessing at which CSS viewport unit actually holds still, yes.js
// becomes the source of truth instead: it freezes .hero-yes's
// rendered height to a literal inline px value once, up front. An
// inline style always wins the cascade over the stylesheet's
// `height: 100svh`, so once this runs, nothing the browser does
// with that unit afterward can move the box - the number is fixed
// in the DOM, not recomputed from a unit at all.
setupCanvas() {
const pixelRatio = window.devicePixelRatio || 1;
const width = window.innerWidth;
const height = window.innerHeight;
if (this.heroEl) {
this.heroEl.style.height = height + 'px';
}
this.rasterCanvas.style.width = width + 'px';
this.rasterCanvas.style.height = height + 'px';
this.lineCanvas.style.width = width + 'px';
this.lineCanvas.style.height = height + 'px';
this.rasterCanvas.width = width * pixelRatio;
this.rasterCanvas.height = height * pixelRatio;
this.lineCanvas.width = width * pixelRatio;
this.lineCanvas.height = height * pixelRatio;
this.rasterCtx.scale(pixelRatio, pixelRatio);
this.lineCtx.scale(pixelRatio, pixelRatio);
this.displayWidth = width;
this.displayHeight = height;
this.pixelRatio = pixelRatio;
}
setupResizeHandler() {
// Gate on width, not height: mobile Safari's address-bar
// animation changes window.innerHeight continuously with no
// real layout change to react to (that's the live value this
// whole method exists to stop trusting). A genuine resize -
// orientation change, desktop window drag - always changes
// width too, so that's the real signal to re-freeze on.
this._resizeHandler = () => {
if (window.innerWidth === this.displayWidth) return;
this.setupCanvas();
this.rasterizeText();
};
window.addEventListener('resize', this._resizeHandler);
}
// The two behavior dials (containmentStrength from x, wiggleAmount
// from y) used to follow the pointer. Now a smooth noise field
// wanders them instead - the same 0..1 inputs a mouse would give,
// but drifting at cloud pace, so the piece breathes on its own and
// behaves identically with nobody touching it (which on a landing
// 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);
};
// One full "weather change" roughly every DRIFT_PERIOD
// 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);
this.containmentStrength = 0.55;
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() {
this.lineCanvas.addEventListener('click', () => {
this.restartAnimation();
});
}
restartAnimation() {
this.time = 0;
this.lineCtx.fillStyle = this.theme.paper;
this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight);
this.initializeLines();
this.isActive = true;
}
rasterizeText() {
const width = this.displayWidth;
const height = this.displayHeight;
const aspectRatio = width / height;
let fontSize;
if (aspectRatio < 1) {
fontSize = width * 0.4;
} else {
fontSize = height * 0.5;
}
this.fontSize = fontSize;
this.rasterCtx.font = `bold ${fontSize}px Arial, sans-serif`;
this.rasterCtx.textAlign = 'left';
this.rasterCtx.textBaseline = 'middle';
const fullTextMetrics = this.rasterCtx.measureText('YES');
const textWidth = fullTextMetrics.width;
const textStartX = (width - textWidth) / 2;
const textY = height / 2;
const letters = ['Y', 'E', 'S'];
this.letterPositions = [];
let currentX = textStartX;
for (let i = 0; i < letters.length; i++) {
const letterMetrics = this.rasterCtx.measureText(letters[i]);
this.letterPositions[i] = {
x: currentX,
y: textY,
width: letterMetrics.width,
centerX: currentX + letterMetrics.width / 2
};
currentX += letterMetrics.width;
}
this.letterRasters = [];
for (let i = 0; i < 3; i++) {
this.rasterCtx.fillStyle = '#111';
this.rasterCtx.fillRect(0, 0, width, height);
this.rasterCtx.fillStyle = '#ffffff';
this.rasterCtx.fillText(letters[i], this.letterPositions[i].x, this.letterPositions[i].y);
this.letterRasters[i] = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height);
}
// 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.fillStyle = this.theme.rasterInk;
this.rasterCtx.fillText('YES', textStartX, textY);
this.rasterData = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height);
}
isInSpecificLetter(x, y, letterIndex) {
const canvasX = x * this.pixelRatio;
const canvasY = y * this.pixelRatio;
if (!this.letterRasters || !this.letterRasters[letterIndex] ||
canvasX < 0 || canvasY < 0 ||
canvasX >= this.rasterCanvas.width || canvasY >= this.rasterCanvas.height) {
return false;
}
const index = (Math.floor(canvasY) * this.rasterCanvas.width + Math.floor(canvasX)) * 4;
const red = this.letterRasters[letterIndex].data[index];
return red > 128;
}
initializeLines() {
this.lines = [];
const numLines = 60;
const letterCentroids = this.calculateLetterCentroids();
for (let i = 0; i < numLines; i++) {
const letterIndex = Math.floor(i / (numLines / 3));
let startX, startY, centerX, centerY;
if (letterCentroids[letterIndex]) {
centerX = letterCentroids[letterIndex].x;
centerY = letterCentroids[letterIndex].y;
const startVariation = this.fontSize * 0.1;
startX = centerX + (Math.random() - 0.5) * startVariation;
startY = centerY + (Math.random() - 0.5) * startVariation;
if (!this.isInSpecificLetter(startX, startY, letterIndex)) {
const nearestPoint = this.findNearestSpecificLetterPixel(startX, startY, letterIndex);
if (nearestPoint) {
startX = nearestPoint.x;
startY = nearestPoint.y;
} else {
startX = centerX;
startY = centerY;
}
}
} else {
const letterPos = this.letterPositions[letterIndex];
startX = letterPos.centerX;
startY = letterPos.y;
centerX = startX;
centerY = startY;
}
// Stroke color lives on the theme, looked up per frame by
// letterIndex (see draw()) - not frozen per line - so a
// theme flip recolors live lines instead of leaving
// dark-theme neon smearing across light paper.
this.lines.push({
relativeX: (startX - centerX) / this.fontSize,
relativeY: (startY - centerY) / this.fontSize,
prevRelativeX: (startX - centerX) / this.fontSize,
prevRelativeY: (startY - centerY) / this.fontSize,
angle: Math.random() * Math.PI * 2,
letterIndex: letterIndex,
lastSeenInside: { x: (startX - centerX) / this.fontSize, y: (startY - centerY) / this.fontSize },
outsideDuration: 0
});
}
}
calculateLetterCentroids() {
const centroids = [];
for (let letterIndex = 0; letterIndex < 3; letterIndex++) {
let sumX = 0, sumY = 0, count = 0;
const letterPos = this.letterPositions[letterIndex];
const searchStartX = Math.max(0, letterPos.x - this.fontSize * 0.1);
const searchEndX = Math.min(this.displayWidth, letterPos.x + letterPos.width + this.fontSize * 0.1);
const searchStartY = Math.max(0, letterPos.y - this.fontSize * 0.6);
const searchEndY = Math.min(this.displayHeight, letterPos.y + this.fontSize * 0.6);
const step = Math.max(1, Math.floor(this.fontSize * 0.02));
for (let y = searchStartY; y <= searchEndY; y += step) {
for (let x = searchStartX; x <= searchEndX; x += step) {
if (this.isInSpecificLetter(x, y, letterIndex)) {
sumX += x;
sumY += y;
count++;
}
}
}
if (count > 0) {
centroids[letterIndex] = {
x: sumX / count,
y: sumY / count
};
} else {
centroids[letterIndex] = {
x: letterPos.centerX,
y: letterPos.y
};
}
}
return centroids;
}
updateLines() {
this.time += 0.016;
this.updateDrift();
if (!this.isActive) return;
const letterCentroids = this.calculateLetterCentroids();
this.lines.forEach(line => {
line.prevRelativeX = line.relativeX;
line.prevRelativeY = line.relativeY;
const centroid = letterCentroids[line.letterIndex];
if (!centroid) return;
const currentX = centroid.x + line.relativeX * this.fontSize;
const currentY = centroid.y + line.relativeY * this.fontSize;
const currentlyInside = this.isInSpecificLetter(currentX, currentY, line.letterIndex);
if (currentlyInside) {
line.outsideDuration = 0;
line.lastSeenInside = { x: line.relativeX, y: line.relativeY };
} else {
line.outsideDuration++;
}
const visionDistance = 0.08 * this.fontSize;
const centerX = currentX + Math.cos(line.angle) * visionDistance;
const centerY = currentY + Math.sin(line.angle) * visionDistance;
const leftX = currentX + Math.cos(line.angle - 0.4) * visionDistance;
const leftY = currentY + Math.sin(line.angle - 0.4) * visionDistance;
const rightX = currentX + Math.cos(line.angle + 0.4) * visionDistance;
const rightY = currentY + Math.sin(line.angle + 0.4) * visionDistance;
const centerSees = this.isInSpecificLetter(centerX, centerY, line.letterIndex);
const leftSees = this.isInSpecificLetter(leftX, leftY, line.letterIndex);
const rightSees = this.isInSpecificLetter(rightX, rightY, line.letterIndex);
let speed = 0.02;
const attractionThreshold = Math.floor(15 + (1 - this.containmentStrength) * 45);
if (line.outsideDuration > attractionThreshold) {
const targetX = line.lastSeenInside.x;
const targetY = line.lastSeenInside.y;
const deltaX = targetX - line.relativeX;
const deltaY = targetY - line.relativeY;
const angleToTarget = Math.atan2(deltaY, deltaX);
let angleDiff = angleToTarget - line.angle;
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
const baseAttraction = Math.min(0.4, line.outsideDuration / 80);
const attractionStrength = baseAttraction * this.containmentStrength;
line.angle += angleDiff * attractionStrength;
}
if (centerSees) {
const baseWiggle = 0.15;
line.angle += (Math.random() - 0.5) * baseWiggle * this.wiggleAmount;
} else {
speed *= (0.3 + this.containmentStrength * 0.4);
const baseTurnStrength = 0.3 + (this.containmentStrength * 0.4);
const randomTurnAmount = 0.2 * this.wiggleAmount;
if (leftSees && !rightSees) {
line.angle -= baseTurnStrength + Math.random() * randomTurnAmount;
} else if (rightSees && !leftSees) {
line.angle += baseTurnStrength + Math.random() * randomTurnAmount;
} else {
const randomTurn = (Math.random() - 0.5) * (0.8 + this.wiggleAmount * 0.7);
line.angle += randomTurn;
}
}
line.relativeX += Math.cos(line.angle) * speed;
line.relativeY += Math.sin(line.angle) * speed;
line.relativeX = Math.max(-1.7, Math.min(1.7, line.relativeX));
line.relativeY = Math.max(-1.7, Math.min(1.7, line.relativeY));
});
}
findNearestSpecificLetterPixel(x, y, letterIndex) {
const searchRadius = this.fontSize * 0.08;
const step = Math.max(1, Math.floor(this.fontSize * 0.01));
let nearestPoint = null;
let nearestDistance = Infinity;
for (let dy = -searchRadius; dy <= searchRadius; dy += step) {
for (let dx = -searchRadius; dx <= searchRadius; dx += step) {
const testX = x + dx;
const testY = y + dy;
if (this.isInSpecificLetter(testX, testY, letterIndex)) {
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestPoint = { x: testX, y: testY };
}
}
}
}
return nearestPoint;
}
draw() {
this.lineCtx.fillStyle = this.theme.fade;
this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight);
const letterCentroids = this.calculateLetterCentroids();
this.lines.forEach(line => {
const centroid = letterCentroids[line.letterIndex];
if (!centroid) return;
const currentX = centroid.x + line.relativeX * this.fontSize;
const currentY = centroid.y + line.relativeY * this.fontSize;
const prevX = centroid.x + line.prevRelativeX * this.fontSize;
const prevY = centroid.y + line.prevRelativeY * this.fontSize;
if (prevX === currentX && prevY === currentY) return;
this.lineCtx.strokeStyle = this.theme.strokes[line.letterIndex];
this.lineCtx.lineWidth = this.fontSize * 0.003;
this.lineCtx.lineCap = 'round';
this.lineCtx.beginPath();
this.lineCtx.moveTo(prevX, prevY);
this.lineCtx.lineTo(currentX, currentY);
this.lineCtx.stroke();
});
}
animate() {
if (this.destroyed) return;
this.updateLines();
this.draw();
requestAnimationFrame(() => this.animate());
}
}
+9
View File
@@ -30,6 +30,12 @@ pub struct AggregateSchema {
pub event_for_state: HashMap<String, String>,
pub state_for_event: HashMap<String, String>,
pub transitions: HashMap<String, Vec<String>>,
/// Names what consumes this bucket's answers when no page in the
/// content repo reads it (e.g. "n8n newsletter compose").
/// Free-text - it exists so `validate_questions`' attended-bucket
/// rule has an explicit, auditable opt-out instead of a silent
/// one, and so the next reader knows where the answers go.
pub attended_by: Option<String>,
}
impl AggregateSchema {
@@ -57,6 +63,8 @@ struct RawAggregateSchema {
states: HashMap<String, RawState>,
#[serde(default)]
transitions: HashMap<String, Vec<String>>,
#[serde(default)]
attended_by: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
@@ -123,6 +131,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
event_for_state,
state_for_event,
transitions: raw_schema.transitions,
attended_by: raw_schema.attended_by,
},
);
}
+184 -156
View File
@@ -1,5 +1,5 @@
use leptos::prelude::*;
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
use leptos_meta::{provide_meta_context, HashedStylesheet, MetaTags, Title};
use leptos_router::{
components::{Route, Router, Routes},
hooks::{use_location, use_navigate, use_query_map},
@@ -39,6 +39,9 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
// once loaded via <link>/<img> on Safari/iOS.
<link rel="icon" media="(prefers-color-scheme: light)" href="/favicon-light.svg" type="image/svg+xml"/>
<link rel="icon" media="(prefers-color-scheme: dark)" href="/favicon-dark.svg" type="image/svg+xml"/>
// Server-side, so it can read hash.txt and link the
// content-hashed stylesheet (hash-files = true).
<HashedStylesheet id="leptos" options=options.clone()/>
<AutoReload options=options.clone()/>
<HydrationScripts options/>
<MetaTags/>
@@ -54,20 +57,60 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
pub fn App() -> impl IntoView {
provide_meta_context();
// ONE site resource and ONE <Title> for the whole app, both living
// outside the router. Two dueling Title components (a static
// SITE_NAME fallback here + a per-page override) turned every SPA
// navigation into a remount race that the compile-time fallback
// kept winning - redoal.com's tab flipped to "uhhm" on the first
// client-side nav. App never remounts, so this Title never
// unmounts; the Suspense makes SSR await the resolved title so the
// served <head> is right too. Pages read the same resource via
// context instead of fetching their own copy.
let site = Resource::new(|| (), |_| get_site());
provide_context(site);
view! {
<Stylesheet id="leptos" href="/pkg/portal.css"/>
<Title text=SITE_NAME/>
<Suspense fallback=|| ()>
{move || {
site.get()
.map(|res| {
let title = res
.ok()
.and_then(|s| s.title)
.unwrap_or_else(|| SITE_NAME.to_string());
view! { <Title text=title/> }
})
}}
</Suspense>
<Router>
<Routes fallback=|| view! { <NotFound/> }>
<Route path=path!("") view=QuestionPage/>
<Route path=path!("/*any") view=QuestionPage/>
</Routes>
<PortalShell/>
</Router>
}
}
/// Every server-fn resource the pages read, created exactly once for
/// the app's lifetime and handed down via context. Wrapper structs
/// because a bare `Resource<T>` context is claimed by whoever provides
/// that T last.
#[derive(Clone, Copy)]
struct QuestionRes(Resource<Result<Option<Page>, ServerFnError>>);
#[derive(Clone, Copy)]
struct UserRes(Resource<Result<Option<User>, ServerFnError>>);
#[derive(Clone, Copy)]
struct NavRes(Resource<Result<Vec<(String, String)>, ServerFnError>>);
/// Owns the app's data resources, above the routes and reactive on the
/// location instead of recreated per page. Route components creating
/// their own resources broke on the first client-side navigation: the
/// remounted component's fresh Resource consumed a stale SSR hydration
/// buffer instead of fetching - concretely, the nav list
/// `[[id, name], ..]` deserialized as a `Page` (serde fills a struct
/// from a sequence in field order), rendering the landing question
/// gated behind its own nav entry. Stable resources + context makes
/// that class of misalignment impossible: navigation only changes a
/// key, and a key change always refetches.
#[component]
fn QuestionPage() -> impl IntoView {
fn PortalShell() -> impl IntoView {
let location = use_location();
let query = use_query_map();
let path = Memo::new(move |_| {
@@ -78,15 +121,37 @@ fn QuestionPage() -> impl IntoView {
p
}
});
let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain")));
let query_email = Memo::new(move |_| query.with(|q| q.get("email")));
let chain = Memo::new(move |_| query.with(|q| q.get("chain")));
let question = Resource::new(
move || (path.get(), parent_hash.get()),
move || (path.get(), chain.get()),
|(path, chain)| get_question(path, chain),
);
let user = Resource::new(|| (), |_| current_user());
let site = Resource::new(|| (), |_| get_site());
let nav = Resource::new(move || chain.get().is_some(), list_qualifying_questions);
provide_context(QuestionRes(question));
provide_context(UserRes(user));
provide_context(NavRes(nav));
view! {
<Routes fallback=|| view! { <NotFound/> }>
<Route path=path!("") view=QuestionPage/>
<Route path=path!("/*any") view=QuestionPage/>
</Routes>
}
}
#[component]
fn QuestionPage() -> impl IntoView {
let query = use_query_map();
let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain")));
let query_email = Memo::new(move |_| query.with(|q| q.get("email")));
// All shared, app-lifetime resources (see PortalShell / App) -
// never created per navigation.
let QuestionRes(question) = expect_context();
let UserRes(user) = expect_context();
let site = expect_context::<Resource<Result<SiteConfig, ServerFnError>>>();
view! {
<Suspense fallback=|| {
@@ -109,13 +174,6 @@ fn QuestionPage() -> impl IntoView {
Ok(Some(page)) => {
let current = user_res.and_then(|r| r.ok()).flatten();
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
question=page.question
chain_gate=page.chain_gate
@@ -144,6 +202,7 @@ fn QuestionView(
site: SiteConfig,
) -> impl IntoView {
let question_id = question.id.clone();
let has_chain = parent_hash.is_some();
// The provenance counterpart to the qualifies gate below: a
// requires_chain page whose visitor holds no verifiable lineage to
@@ -170,6 +229,8 @@ fn QuestionView(
</a>
</section>
</div>
// A gate is never a dead end: the same nav as every page.
<QuestionNav current_id=question_id has_chain=has_chain/>
}
.into_any();
}
@@ -205,6 +266,7 @@ fn QuestionView(
}}
</section>
</div>
<QuestionNav current_id=question_id has_chain=has_chain/>
}
.into_any();
}
@@ -214,7 +276,6 @@ fn QuestionView(
// on pages where some alternative actually declares a deck.
let needs_swiper = question.alternatives.iter().any(|a| a.images.len() > 1);
let has_chain = parent_hash.is_some();
view! {
{needs_swiper.then(|| view! { <leptos_meta::Script src="/swiper-element-bundle.min.js"/> })}
@@ -277,7 +338,8 @@ fn QuestionView(
/// claiming front-page space (an owner also sees the gated desks here).
#[component]
fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView {
let nav = Resource::new(move || has_chain, list_qualifying_questions);
let _ = has_chain; // keyed into the shared resource by PortalShell
let NavRes(nav) = expect_context();
view! {
<Suspense fallback=|| ()>
{move || {
@@ -347,32 +409,6 @@ fn ResponsibleNote(responsible: Responsible) -> impl IntoView {
}
}
// Binds the actual "YES - Rasterized Lines" piece live at uhhm.no
// (ported to `public/yes.js`, an ES module now instead of a page-owning
// script) as a typed JS class - the idiomatic wasm-bindgen way to drive
// an existing JS module from Rust, rather than re-implementing canvas
// animation logic here.
#[cfg(feature = "hydrate")]
mod yes {
use wasm_bindgen::prelude::*;
// raw_module (a runtime URL, not a bundled snippet) on purpose:
// the file lives in public/ so it's served at the stable /yes.js
// - the same URL the hero's inline early-mount script imports.
// A bundled snippet would sit under a per-build hashed
// /pkg/snippets/ path the inline script couldn't know.
#[wasm_bindgen(raw_module = "/yes.js")]
extern "C" {
#[wasm_bindgen(js_name = RasterizedYES)]
pub type RasterizedYes;
#[wasm_bindgen(constructor, js_class = "RasterizedYES")]
pub fn new() -> RasterizedYes;
#[wasm_bindgen(method)]
pub fn stop(this: &RasterizedYes);
}
}
// Same idiomatic-typed-module approach as `yes` above, for the
// prosekit-backed rich-text requirement kind (`prosekit-editor.js`,
@@ -418,12 +454,6 @@ mod gesture {
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);
}
@@ -431,131 +461,103 @@ mod gesture {
#[component]
fn Hero(title: String, description: String, landing: bool, site: SiteConfig) -> impl IntoView {
// Only the landing page gets a full interactive piece - it's the
// one page a hero is actually "the" hero for; other pages
// (thank-you pages, /review) get the plain dark header below.
// WHICH piece is the content repo's call (`site.yaml`'s
// hero.kind): the YES canvas (default - uhhm's look), a redoal
// gesture canvas, or nothing.
let hero_kind = if landing { site.hero.kind.clone() } else { "plain".to_string() };
let show_yes = hero_kind == "yes";
let show_gesture = hero_kind == "gesture";
// Only the landing page gets a piece - it's the one page a hero is
// actually "the" hero for; every other page gets the plain header.
// WHAT the piece is belongs to the content repo: site.yaml's
// `hero: {kind: module, module: hero.js}` names a JavaScript module
// (served same-origin at /site/<path>) exporting
// `mount(container) -> handle` with `handle.stop()`. Portal knows
// nothing about what it draws.
let module = if landing && site.hero.kind == "module" {
site.hero.module.clone()
} else {
None
};
let has_module = module.is_some();
let wordmark = site.wordmark.clone().unwrap_or_else(|| "/wordmark.svg".to_string());
let site_title = site.title.clone().unwrap_or_else(|| SITE_NAME.to_string());
// Starts the YES animation once the canvas elements exist, and
// explicitly stops the requestAnimationFrame loop on unmount - the
// original page-owning script never needed this since navigating
// away meant a full document unload, which doesn't happen in an
// SPA.
let raster_ref: NodeRef<leptos::html::Canvas> = NodeRef::new();
let gesture_ref: NodeRef<leptos::html::Div> = NodeRef::new();
let piece_ref: NodeRef<leptos::html::Div> = NodeRef::new();
#[cfg(feature = "hydrate")]
if show_yes {
// `on_cleanup` requires Send + Sync (even single-threaded, wasm),
// which a JS-backed value never is - `StoredValue`'s `LocalStorage`
// variant is the established way around this (same pattern
// cnats' WebRTC call state uses for its own non-Send peer map).
let instance: StoredValue<Option<yes::RasterizedYes>, LocalStorage> =
if has_module {
use wasm_bindgen::JsCast;
// Handle = whatever the module's mount() returned; kept as a
// raw JsValue so the contract stays "has a stop()", nothing
// typed on the Rust side.
let handle: StoredValue<Option<wasm_bindgen::JsValue>, LocalStorage> =
StoredValue::new_local(None);
// Gated on the canvas NodeRef resolving, not just "the Effect
// ran" - on a fresh page load the DOM is already there by the
// time this fires, but navigating back to `/` client-side hit a
// real race: the Effect ran before the new view's <canvas> was
// actually inserted, RasterizedYES::new() (yes.js) did an
// unchecked `document.getElementById(...).getContext(...)` on
// null and threw mid-reactive-update - which then corrupted
// wasm_bindgen_futures' single-threaded executor state badly
// enough to panic ("RefCell already borrowed") on the next
// tick. Waiting for the NodeRef itself guarantees DOM presence
// the same way the prosekit editor's own mount Effect does.
Effect::new(move |_| {
if raster_ref.get().is_none() {
let Some(container) = piece_ref.get() else {
return;
};
if handle.with_value(|h| h.is_some()) {
return;
}
if instance.with_value(|i| i.is_some()) {
return;
}
// Adopt the hero's inline early-mount instance (started at
// HTML parse time, long before this wasm was even fetched)
// instead of starting a second animation. The flag covers
// the opposite ordering too: an inline script that runs
// after this sees it and stays inert.
use wasm_bindgen::JsCast;
// Adopt the inline early-mount's instance (started at HTML
// parse time) if it's there; otherwise (client-side
// navigation back to /) mount fresh through the helper the
// inline script left on window. Either order is safe.
let global = js_sys::global();
let _ = js_sys::Reflect::set(&global, &"__yesAdopted".into(), &true.into());
let early = js_sys::Reflect::get(&global, &"__yesEarly".into())
let _ = js_sys::Reflect::set(&global, &"__heroAdopted".into(), &true.into());
let early = js_sys::Reflect::get(&global, &"__heroEarly".into())
.ok()
.filter(|v| !v.is_undefined() && !v.is_null());
let inst = match early {
match early {
Some(v) => {
let _ = js_sys::Reflect::delete_property(&global, &"__yesEarly".into());
v.unchecked_into::<yes::RasterizedYes>()
let _ = js_sys::Reflect::delete_property(&global, &"__heroEarly".into());
handle.set_value(Some(v));
}
None => yes::RasterizedYes::new(),
};
instance.set_value(Some(inst));
None => {
if let Ok(mounter) = js_sys::Reflect::get(&global, &"__mountHero".into()) {
if let Some(f) = mounter.dyn_ref::<js_sys::Function>() {
if let Ok(promise) = f.call1(&global, &container) {
let promise: js_sys::Promise = promise.unchecked_into();
leptos::task::spawn_local(async move {
if let Ok(h) =
wasm_bindgen_futures::JsFuture::from(promise).await
{
handle.set_value(Some(h));
}
});
}
}
}
}
}
});
on_cleanup(move || {
instance.update_value(|opt| {
if let Some(inst) = opt.take() {
inst.stop();
handle.update_value(|opt| {
if let Some(h) = opt.take() {
if let Ok(stop) = js_sys::Reflect::get(&h, &"stop".into()) {
if let Some(f) = stop.dyn_ref::<js_sys::Function>() {
let _ = f.call0(&h);
}
}
}
});
});
}
// 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();
}
});
});
}
// The inline script starts the piece at HTML parse time instead of
// waiting out the wasm bundle; it also leaves __mountHero for the
// hydrate side's client-side-navigation case. Module path is
// validated to plain segments by SiteConfig::validate, so it can't
// break out of the string here.
let early_script = module.as_ref().map(|m| {
format!(
"const url = '/site/{m}'; window.__mountHero = (el) => import(url).then((mod) => mod.mount(el)); if (!window.__heroAdopted && !window.__heroEarly) window.__mountHero(document.querySelector('.hero-piece')).then((h) => {{ if (!window.__heroAdopted) window.__heroEarly = h; else if (h && h.stop) h.stop(); }});"
)
});
view! {
<header class="hero" class:hero-yes=show_yes class:hero-gesture=show_gesture>
{show_yes
<header class="hero" class:hero-module=has_module>
{has_module
.then(|| {
view! {
<div class="hero-canvas" aria-hidden="true">
<canvas id="lineCanvas"></canvas>
<canvas id="rasterCanvas" node_ref=raster_ref></canvas>
</div>
// Starts the animation at HTML parse time
// instead of waiting out the wasm bundle's
// fetch + hydration; the hydrate Effect above
// adopts (never duplicates) the instance, and
// the guards make either execution order safe.
<script
type="module"
inner_html="import('/yes.js').then((m) => { if (!window.__yesAdopted && !window.__yesEarly) window.__yesEarly = new m.RasterizedYES(); });"
></script>
<div class="hero-piece" aria-hidden="true" node_ref=piece_ref></div>
<script type="module" inner_html=early_script.unwrap_or_default()></script>
}
})}
{show_gesture
.then(|| {
view! { <div class="hero-draw" node_ref=gesture_ref></div> }
})}
<div class="hero-copy">
<a class="wordmark" href="/">
<img src=wordmark alt=site_title/>
@@ -898,6 +900,7 @@ fn AlternativeCard(
alternative=alt_name.clone()
feature_name=feature_name.clone()
transitions=spec.transitions.clone()
empty=spec.empty.clone()
pending_transitions=pending_transitions
transition_batch=transition_batch
/>
@@ -1174,7 +1177,17 @@ fn AlternativeCard(
children=move |e| view! { <p class="alt-encouragement">{e}</p> }
/>
</div>
{if is_gateway {
{if alternative.disabled {
// Advertised, not yet takeable: the label
// stays (it says what will be possible),
// the button doesn't act.
view! {
<button type="button" class="alt-submit soon" disabled=true title="Not yet">
{button_label.clone()}
</button>
}
.into_any()
} else if is_gateway {
let href = alternative.action.clone().unwrap_or_default();
view! {
<a class="alt-submit" href=href>{button_label.clone()}</a>
@@ -1217,6 +1230,7 @@ fn ResourceFeature(
alternative: String,
feature_name: String,
transitions: Vec<Transition>,
empty: Option<String>,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>,
) -> impl IntoView {
@@ -1250,6 +1264,7 @@ fn ResourceFeature(
{move || {
let feature_name = feature_name.clone();
let transitions = transitions.clone();
let empty = empty.clone().unwrap_or_else(|| "Nothing here yet.".to_string());
data.get()
.map(|res| match res {
Ok(value) => {
@@ -1257,6 +1272,7 @@ fn ResourceFeature(
<ResourceValue
feature_name=feature_name
transitions=transitions
empty=empty
value=value
pending_transitions=pending_transitions
transition_batch=transition_batch
@@ -1283,11 +1299,23 @@ fn ResourceFeature(
fn ResourceValue(
feature_name: String,
transitions: Vec<Transition>,
empty: String,
value: serde_json::Value,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>,
) -> impl IntoView {
// A jq like `.[] | {...}` over an empty list yields no output at
// all - that arrives as null, and it's the same silence as [].
if value.is_null() {
return view! { <p class="resource-empty">{empty}</p> }.into_any();
}
if let serde_json::Value::Array(items) = &value {
// Before the Answer parse below: an empty list deserializes as
// an (empty) Vec<Answer> too, and rendered as a blank
// answer-list instead of saying anything.
if items.is_empty() {
return view! { <p class="resource-empty">{empty}</p> }.into_any();
}
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
let mut answers = answers;
answers.sort_by(|a, b| {
@@ -1320,9 +1348,6 @@ fn ResourceValue(
}
.into_any();
}
if items.is_empty() {
return view! { <p class="resource-empty">"Nothing here yet."</p> }.into_any();
}
// A list of plain objects (e.g. a jq-shaped GiteaStarred/Url
// resource) - render as cards rather than dumping raw JSON.
// Generic on purpose, no content-declared "kind": `name`/`title`
@@ -1845,6 +1870,9 @@ pub async fn submit_answer(
.iter()
.find(|a| a.name == alternative)
.ok_or_else(|| ServerFnError::new("unknown alternative"))?;
if alt.disabled {
return Err(ServerFnError::new("this alternative isn't open yet"));
}
let next = alt.action.clone();
let record_as = alt.record_as.clone();
+211 -29
View File
@@ -111,6 +111,11 @@ pub struct Alternative {
pub description: String,
#[serde(default)]
pub action: Option<String>,
/// Shown but not takeable yet - the button renders disabled and the
/// server refuses the submission. For advertising a path before it
/// works (lysbue had the same flag).
#[serde(default)]
pub disabled: bool,
#[serde(default)]
pub consequence: Vec<String>,
#[serde(default)]
@@ -202,6 +207,12 @@ pub struct ResourceSpec {
/// `.[] | {name, url: .html_url}`. `None` returns it as-is.
#[serde(default)]
pub jq: Option<String>,
/// What to say when the resource yields nothing - an empty list,
/// or `null` (a `.[] | ...` jq over an empty list produces no
/// output at all). Defaults to "Nothing here yet."; content sets
/// it where the silence needs a voice ("Nothing released yet").
#[serde(default)]
pub empty: Option<String>,
}
/// Where a resource's live data comes from. `Kv` (a NATS KV bucket
@@ -347,51 +358,76 @@ pub struct SiteConfig {
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`).
/// What the landing page's hero is. `plain` is just the copy; `module`
/// hands the hero to a JavaScript module the content repo itself
/// ships (`module: hero.js`, a repo-relative path portal serves
/// same-origin at `/site/<path>`, since Gitea's raw endpoint sends no
/// CORS headers). The module exports `mount(container) -> handle` and
/// the handle has `stop()`; portal starts it at HTML parse time and
/// adopts it on hydration. The piece's look - canvases, SVG, CSS - is
/// entirely the module's: portal only reserves the box.
#[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>,
pub module: Option<String>,
}
impl Default for HeroConfig {
fn default() -> Self {
Self { kind: default_hero_kind(), relay: None }
Self { kind: default_hero_kind(), module: None }
}
}
fn default_hero_kind() -> String {
"yes".to_string()
"plain".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.
/// Same policy as `validate_questions`: content declares, the
/// runtime refuses what it doesn't understand at load time, 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");
match self.hero.kind.as_str() {
"plain" => {
if self.hero.module.is_some() {
anyhow::bail!("site.yaml: hero.module only makes sense with hero.kind: module");
}
}
validate_relay_url(relay).map_err(|e| anyhow::anyhow!("site.yaml: {e}"))?;
"module" => {
let Some(module) = &self.hero.module else {
anyhow::bail!("site.yaml: hero.kind: module needs hero.module (a repo-relative path like hero.js)");
};
if !is_safe_site_path(module) {
anyhow::bail!(
"site.yaml: hero.module {module:?} must be a plain repo-relative path (no scheme, no .., no leading /)"
);
}
}
other => anyhow::bail!("site.yaml: hero.kind {other:?} is not one of plain | module"),
}
Ok(())
}
}
/// A repo-relative asset path portal is willing to proxy from the
/// content repo: plain segments only - no traversal, no leading slash,
/// no scheme - so `/site/<path>` can only ever reach the content repo.
pub fn is_safe_site_path(path: &str) -> bool {
!path.is_empty()
&& !path.starts_with('/')
&& path.split('/').all(|seg| {
!seg.is_empty()
&& seg != ".."
&& seg
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
})
}
/// A relay must be a ws:// or wss:// URL - shared between site.yaml's
/// hero and `Requirement.relay` validation.
#[cfg(feature = "ssr")]
@@ -428,6 +464,15 @@ pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Resul
/// Extracts `scheme://host` from a repo's normal browser URL - the
/// Gitea API base every helper in this module builds requests against.
#[cfg(feature = "ssr")]
/// `{gitea}/api/v1/repos/{owner}/{repo}/raw` - the base
/// `site_asset_handler` proxies content-shipped files from.
#[cfg(feature = "ssr")]
pub fn gitea_raw_base(repo_url: &str) -> anyhow::Result<String> {
let (owner, repo) = parse_owner_repo(repo_url)?;
Ok(format!("{}/api/v1/repos/{owner}/{repo}/raw", gitea_api_base(repo_url)?))
}
#[cfg(feature = "ssr")]
pub fn gitea_api_base(repo_url: &str) -> anyhow::Result<String> {
let parsed = url::Url::parse(repo_url)
@@ -948,6 +993,54 @@ pub fn validate_questions(
}
}
}
// Attended buckets: no publicly collected answer may land somewhere
// nothing reads. Every `record_as` bucket must either be read back
// by some Kv resource in this same content repo (a desk or listing)
// or carry an explicit `attended_by:` annotation in aggregates.yaml
// naming the automation that consumes it. This is a contract, not a
// convention, because convention already failed once: a question
// was dropped and its bucket - answers included - silently fell out
// of every reader's view.
let mut read_buckets = std::collections::HashSet::new();
let note_resource = |spec: &ResourceSpec, set: &mut std::collections::HashSet<String>| {
if let ResourceSource::Kv { bucket } = &spec.source {
set.insert(bucket.clone());
}
};
for question in questions.values() {
for alternative in &question.alternatives {
for feature in &alternative.features {
if let Some(spec) = &feature.resource {
note_resource(spec, &mut read_buckets);
}
for requirement in &feature.requirements {
if let Some(spec) = &requirement.resource {
note_resource(spec, &mut read_buckets);
}
if let Some(bind) = &requirement.bind {
note_resource(&bind.resource, &mut read_buckets);
}
}
}
}
}
for question in questions.values() {
for alternative in &question.alternatives {
if let Some(bucket) = &alternative.record_as {
let attended = read_buckets.contains(bucket)
|| aggregates
.get(bucket)
.is_some_and(|schema| schema.attended_by.is_some());
if !attended {
anyhow::bail!(
"question {:?} alternative {:?}: record_as {bucket:?} is unattended - no page reads that bucket back, and aggregates.yaml declares no attended_by for it. Add a desk/listing resource over it, or annotate the automation that consumes it.",
question.id, alternative.name
);
}
}
}
}
Ok(())
}
@@ -1043,6 +1136,54 @@ pub struct GiteaRepoQuery {
pub repo: String,
}
/// `GET /site/{*path}` - serves a file from the content repo's default
/// branch same-origin, so content-shipped assets (the hero module,
/// its stylesheet) are importable by the page: Gitea's raw endpoint
/// sends no CORS headers, and an ES module import across origins is
/// refused without them. Path is validated to plain segments
/// (`is_safe_site_path`), the response revalidates like /pkg
/// (Cache-Control: no-cache) so a content push shows up on reload.
#[cfg(feature = "ssr")]
pub async fn site_asset_handler(
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
axum::extract::Path(path): axum::extract::Path<String>,
) -> axum::response::Response {
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
if !is_safe_site_path(&path) {
return (StatusCode::BAD_REQUEST, "invalid asset path").into_response();
}
let url = format!("{}/{path}?ref={}", state.content_raw_base, state.content_branch);
let client = openidconnect::reqwest::Client::new();
let resp = match client.get(&url).send().await.and_then(|r| r.error_for_status()) {
Ok(r) => r,
Err(e) => {
tracing::warn!("site asset {path}: {e}");
return (StatusCode::NOT_FOUND, "no such site asset").into_response();
}
};
let bytes = match resp.bytes().await {
Ok(b) => b,
Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
};
let mime = match path.rsplit('.').next() {
Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("json") => "application/json",
Some("png") => "image/png",
Some("webp") => "image/webp",
Some("woff2") => "font/woff2",
_ => "application/octet-stream",
};
(
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, "no-cache")],
bytes,
)
.into_response()
}
/// Looks up `owner/repo` on the same Gitea instance content is loaded
/// from (`AppState.gitea_base`) - a raw Axum handler (mounted at
/// `/gitea-repo` in `main.rs`), not a Leptos server fn, since the
@@ -1338,9 +1479,9 @@ alternatives:
}
#[test]
fn site_config_defaults_to_the_yes_hero() {
fn site_config_defaults_to_the_plain_hero() {
let site = SiteConfig::default();
assert_eq!(site.hero.kind, "yes");
assert_eq!(site.hero.kind, "plain");
assert!(site.validate().is_ok());
// And an empty file parses to the same thing.
let parsed: SiteConfig = serde_yaml::from_str("{}").unwrap();
@@ -1355,21 +1496,28 @@ alternatives:
}
#[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());
fn site_config_module_hero_needs_a_safe_path() {
let missing: SiteConfig = serde_yaml::from_str("hero: { kind: module }").unwrap();
assert!(missing.validate().is_err());
let traversal: SiteConfig =
serde_yaml::from_str("hero: { kind: module, module: ../etc/passwd }").unwrap();
assert!(traversal.validate().is_err());
let absolute: SiteConfig =
serde_yaml::from_str("hero: { kind: module, module: https://x/y.js }").unwrap();
assert!(absolute.validate().is_err());
let ok: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: hero.js }").unwrap();
assert!(ok.validate().is_ok());
}
#[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",
"title: redoal\nwordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg\nhero:\n kind: module\n module: hero.js\n",
)
.unwrap();
assert!(site.validate().is_ok());
assert_eq!(site.title.as_deref(), Some("redoal"));
assert_eq!(site.hero.kind, "gesture");
assert_eq!(site.hero.module.as_deref(), Some("hero.js"));
}
#[test]
@@ -1501,6 +1649,40 @@ alternatives:
assert!(validate_questions(&questions, &Default::default()).is_err());
}
#[test]
fn recorded_buckets_must_be_attended() {
let write_only = vec![(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: send\n record_as: black_hole\n".to_string(),
)];
let questions = build_questions(&write_only).unwrap();
let err = validate_questions(&questions, &Default::default()).unwrap_err();
assert!(err.to_string().contains("unattended"), "got: {err}");
// A desk (any Kv resource over the bucket, anywhere in the
// repo) attends it.
let with_desk = vec![
(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: send\n record_as: inbox\n".to_string(),
),
(
"review/index.yaml".to_string(),
"name: Desk\nalternatives:\n - name: Inbox\n features:\n - name: \"\"\n resource:\n requires_group: owners\n source:\n kind: kv\n bucket: inbox\n".to_string(),
),
];
let questions = build_questions(&with_desk).unwrap();
assert!(validate_questions(&questions, &Default::default()).is_ok());
// ..or an explicit attended_by annotation in aggregates.yaml.
let aggregates = crate::aggregates::parse_aggregates_yaml(
"aggregates:\n - bucket: black_hole\n initial: open\n attended_by: n8n nightly digest\n states:\n open: { event: submitted }\n",
)
.unwrap();
let questions = build_questions(&write_only).unwrap();
assert!(validate_questions(&questions, &aggregates).is_ok());
}
#[test]
fn actions_may_not_target_dynamic_pages() {
let files = vec![
+22 -7
View File
@@ -29,6 +29,7 @@ async fn main() -> anyhow::Result<()> {
.unwrap_or_else(|_| "https://project.uhhm.no/uhhm/questions".to_string());
let content_branch = std::env::var("CONTENT_BRANCH").unwrap_or_else(|_| "main".to_string());
let gitea_base = content::gitea_api_base(&content_repo)?;
let content_raw_base = content::gitea_raw_base(&content_repo)?;
let aggregates = content::load_aggregates_from_gitea(&content_repo, &content_branch).await?;
let questions = content::load_questions_from_gitea(&content_repo, &content_branch, "questions").await?;
content::validate_questions(&questions, &aggregates)?;
@@ -67,7 +68,7 @@ async fn main() -> anyhow::Result<()> {
tokio::spawn(content::watch_for_reload(
nats.clone(),
content_repo,
content_branch,
content_branch.clone(),
"questions".to_string(),
questions.clone(),
aggregates.clone(),
@@ -82,6 +83,8 @@ async fn main() -> anyhow::Result<()> {
aggregates,
site,
gitea_base,
content_raw_base,
content_branch: content_branch.clone(),
oidc: oidc_state,
garage,
};
@@ -129,10 +132,6 @@ async fn main() -> anyhow::Result<()> {
let favicon_dark_path = format!("{}/favicon-dark.svg", leptos_options.site_root);
let wordmark_path = format!("{}/wordmark.svg", leptos_options.site_root);
let swiper_path = format!("{}/swiper-element-bundle.min.js", leptos_options.site_root);
// yes.js sits in public/ (not a wasm-bindgen snippet) so the
// hero's inline early-mount script and the wasm binding can share
// one stable URL - see `mod yes` in app.rs.
let yes_path = format!("{}/yes.js", leptos_options.site_root);
let fonts_dir = format!("{}/fonts", leptos_options.site_root);
let app = Router::new()
@@ -142,8 +141,25 @@ async fn main() -> anyhow::Result<()> {
.route("/api/{*fn_name}", any(server_fn_handler))
.route("/upload", post(upload::upload))
.route("/gitea-repo", get(content::gitea_repo_handler))
// Content-shipped assets (hero module etc.), same-origin.
.route("/site/{*path}", get(content::site_asset_handler))
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
.nest_service("/pkg", ServeDir::new(pkg_dir))
// no-cache = "revalidate before reuse", not "don't cache":
// pkg files keep the same names across releases (portal.js,
// portal.wasm), and without this browsers heuristically cache
// them - a stale wasm from the previous release then talks to
// a server whose server-fn wire format has moved on and every
// page renders as its error branch. A 304 per load is the
// price of never shipping that skew again.
.nest_service(
"/pkg",
tower::ServiceBuilder::new()
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-cache"),
))
.service(ServeDir::new(pkg_dir)),
)
.nest_service("/fonts", ServeDir::new(fonts_dir))
.route_service("/favicon-light.svg", ServeFile::new(favicon_light_path))
.route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path))
@@ -152,7 +168,6 @@ async fn main() -> anyhow::Result<()> {
"/swiper-element-bundle.min.js",
ServeFile::new(swiper_path),
)
.route_service("/yes.js", ServeFile::new(yes_path))
.leptos_routes_with_context(
&state,
routes,
+4
View File
@@ -38,6 +38,10 @@ pub struct AppState {
/// rather than re-derived per call, since `resolve_gitea_repo` needs
/// it too and has no other reason to see `CONTENT_REPO` itself.
pub gitea_base: String,
/// `{gitea}/api/v1/repos/{owner}/{repo}/raw` and the branch - what
/// `content::site_asset_handler` proxies content-shipped assets from.
pub content_raw_base: String,
pub content_branch: String,
pub oidc: Arc<oidc::Oidc>,
/// `None` when `GARAGE_*` env vars aren't set - uploads are the one
/// optional feature, everything else works without Garage.
+26 -99
View File
@@ -51,7 +51,7 @@
--ink-dim: #9a9a9a;
--line: #2a2a2a;
/* Cards float over the sticky YES canvas - translucent so the piece
/* Cards float over a sticky hero piece - 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. */
@@ -78,8 +78,9 @@
/* 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. */
#8ec2c0 washes out there). Canvas-drawing modules (gesture.js, and
content-shipped hero modules) mirror this palette on their own,
since canvas paint can't read CSS vars. */
@media (prefers-color-scheme: light) {
:root {
--accent: #47807e;
@@ -105,12 +106,6 @@
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;
}
}
* {
@@ -200,98 +195,17 @@ main.not-found {
max-width: 46ch;
}
/* the actual "YES - Rasterized Lines" piece live at uhhm.no (see
public/yes.js) - landing page only. Full-viewport so it's a real
moment, not a thumbnail; hero-copy overlays near the bottom rather
than interrupting the canvas. */
.hero-yes {
/* 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;
width: 100%;
/* svh here is only the pre-JS/no-JS fallback (and first paint before
yes.js's constructor runs). Once yes.js mounts, it overwrites this
with an inline `height: <px>` frozen from a single measurement -
see setupCanvas()'s comment in yes.js for why: on real mobile
Safari, content bottom-aligned inside this box kept sliding down
as the address bar collapsed even with a spec'd-stable viewport
unit here, so the box's actual height can't be trusted to stay
put on that unit alone. An inline style set from JS always wins
the cascade over this rule, so that frozen number is what
actually governs once the page is interactive.
Plain-vh fallback declared first - an engine without svh support
ignores the invalid second line rather than falling through to
auto height, which would collapse this to the height of its
in-flow content and clip the canvas via overflow:hidden below. */
height: 100vh;
height: 100svh;
padding: 0;
justify-content: flex-end;
overflow: hidden;
}
.hero-canvas {
position: absolute;
inset: 0;
}
.hero-canvas canvas {
position: absolute;
top: 0;
left: 0;
cursor: pointer;
}
.hero-canvas #rasterCanvas {
mix-blend-mode: overlay;
opacity: 0.5;
}
.hero-yes .hero-copy {
position: relative;
z-index: 1;
padding: 0 1.5rem 3.5rem;
gap: 0.6rem;
text-shadow: 0 0.12em 1.4em var(--hero-glow);
}
/* The gesture hero (site.yaml hero.kind: gesture - redoal.com's
landing): a tall drawing surface where the visitor's stroke and
ambient echoes from the relay share the stage. Not sticky like the
YES piece - drawing and scrolling fight over the same finger. */
.hero-gesture .hero-draw {
/* A content-shipped hero module (site.yaml hero.kind: module) draws
into .hero-piece; the module owns its look (it may restyle .hero
itself). Portal only reserves the box so the copy doesn't jump when
the module mounts - plain-vh fallback first, as everywhere. */
.hero-module .hero-piece {
position: relative;
width: 100%;
max-width: 46rem;
/* Same jump-avoidance as .gesture-wrap: the canvas (55svh tall)
only exists after hydration, so hold its height open from the
first paint. Plain-vh fallback first, like the canvas itself. */
min-height: 55vh;
min-height: 55svh;
}
.hero-gesture .gesture-canvas {
aspect-ratio: auto;
/* Plain-vh fallback first, same reasoning as .hero-yes's height. */
height: 55vh;
height: 55svh;
border: none;
background: transparent;
}
.hero-gesture .gesture-status {
top: auto;
bottom: 0.55rem;
right: 0.55rem;
}
/* alternatives */
/* position + z-index on these three: everything that scrolls over the
@@ -564,10 +478,16 @@ textarea:focus {
custom properties - so palette changes go there AND here. */
.gesture-wrap {
position: relative;
/* The canvas is created by JS only after wasm hydration - reserve
its 3/2 box now so content below doesn't jump when it appears.
aspect-ratio is a preferred size, so the box still grows when the
echo thumbnail strip shows up under the canvas. */
width: 100%;
}
/* The canvas is created by JS only after wasm hydration - reserve its
3/2 box until then so content below doesn't jump. Scoped to :empty
on purpose: on the mounted wrap (a stretched item inside the flex
.field) the same aspect-ratio turned the echo strip's extra height
into extra WIDTH and the whole card ballooned past the viewport
after the first stroke. */
.gesture-wrap:empty {
aspect-ratio: 3 / 2;
}
@@ -755,6 +675,13 @@ textarea:focus {
cursor: wait;
}
/* A content-disabled alternative (`disabled: true`): announced, not
yet takeable - reads as a promise, not as a stuck form. */
.alt-submit.soon:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.gate-card {
text-align: center;
}