slideshow on a page: max_height in svh, slides with an image (a path
in the content repo or https), a caption laid over the picture's foot,
and optionally the picture's size so its place is held before it
loads. Swiper upgrades it; before that it is a row that scrolls.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
Three small things that together make a board work: a link on every
listed record to a page about it (ResourceSpec.link, {id} and {field}
in the address); a state mail whose `to` is a field of the record this
one belongs to ({notice.email}), with that record's fields in the
text, and a lint that the form carries the record field it reaches
through; and value: "{user.email}" filled from the session and hidden,
so someone the site knows is not asked who they are.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
0.5.3 put the same link in two places: in words at the foot of the
page, and as an arrow fixed at the left edge half-way up the screen.
The arrow stayed with the reader the whole way down a page to offer
what the foot offers where they end up anyway. It is removed; the foot
link and where it leads are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
Five things the first cut of accounts-from-content left open.
The first person and the responsible people are set up side by side,
and both try again a minute apart: a seed that waited twenty minutes on
Kanidm used to hold everyone else up, and a responsible person who
failed waited for the next restart.
The responsible are gone over again whenever the content changes.
watch_for_reload publishes portal.content.reloaded once new content is
in place, and seed.rs listens.
Being responsible ends when the content stops saying so: someone no
page names any more leaves RESPONSIBLE_GROUP in the same pass, if
portal is who put them there (Invited.already_member). The account
stays.
An invite keeps itself current. portal_invites has an expired state;
signing in closes a person's invites, a sweep moves the ones whose link
ran out, and a desk's move from expired back to open makes a fresh link
and mails it. Invites made at start are on file with the desks'.
A suggested edit is a case in the built-in portal_edit_suggestions
bucket, the person who made it is mailed at each move, and the card
lists what they sent and where it stands.
Found on the way, against Kanidm 1.11.2: a link's end comes as seconds
since the epoch, which portal did not read, so invite mails said
"until" and nothing; and a mail search answered with a 5xx was taken
for a refusal.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
Every person a page names as responsible gets a Kanidm account at start
if they have none, and a mail saying they are listed as responsible for
asking that question, with the link to sign in; RESPONSIBLE_GROUP puts
them in one group. SEED_ADMIN_EMAIL invites the first person into the
most privileged group - the one whose desk may invite into the most -
once. Signed in, a responsible person may suggest a change to their
own pages; checked on the server, kept, published, and mailed to the
site's inbox.
Fixes an invite of someone new on a Kanidm with only the onboarding
account: the person is created with their address in one call, since
idm_people_on_boarding may create but not modify (it answered 403).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
An arrow fixed at the left edge, half-way up the screen, and the same
link in words at the foot of the page. It goes one level down: the
nearest page below the current path that the visitor may open, found by
taking the path apart a segment at a time, and the front door when none
of those is a page (/status/oppslag -> /, /review/abc -> /review). The
front door itself has none. Labelled with where it goes: 'Tilbake til
starten', or the target page's question in quotation marks.
Content stops needing a 'back' alternative on every page, and a visitor
who went up a branch whose context did not apply is never stranded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
The Suspense fallback on a question is the whole served page until
the question resolves - no header, no wordmark - so the word
"loading…" was the first and only thing a visitor or a crawler read
about the site. True, and not about them. It holds its space and
stays quiet now; `aria-busy` says what is happening to a reader that
would otherwise find an empty document. Same for the two resource
slots inside a page, which keep a line's worth of room.
Also gone: the `gitea_org_repos` resource kind, which no site ever
named. `gitea_starred` and `gitea_releases` stay.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Whatever reads the mailbox publishes on portal.mail.received; portal
appends the note after checking the record exists in a bucket this
site has and the sender is the address the record itself holds. A
note never moves a case: a mailbox is the one door anyone may knock
on, so it cannot drive a state machine even if someone forges their
way past the checks. The desks show the conversation beside the case.
Tested: the address parser, every refusal (a stranger, no address to
compare, an empty reply, the same reply twice, an unknown contract),
and the whole path through a real JetStream - the note lands, the
state does not move, and no event is written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`type: location` puts a pin on a map and stores {lat, lon, zoom}; a
record that holds one renders as a still, on a desk and in any
resource card. `place:` on a question is the counterpart to `event:`:
event makes a page an occasion with a time, place makes it somewhere,
shows the map under the question, and opens any location field on the
page there instead of on the globe.
Every tile and every still is fetched by portal and served from this
origin (src/maps.rs, MapLibre vendored in public/), so a page with a
map on it still talks to nobody but us: no third-party request, no
consent banner, and the token never leaves the server. MAPBOX_TOKEN
unset turns the whole thing off, the way GARAGE_* does for uploads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`list_qualifying_questions` reduced the visitor's `?chain=` to a
bool, so one followup path unlocked every followup on the site -
redoal's `/watching` (an email-link page whose header says "never in
the nav") showed up for anyone who had just subscribed.
The nav now resolves the chain hash to the one question it verifiably
answered (the same `lookup_node` a `requires_chain` gate uses) and
offers a followup only when it is on that path or further down from
the current page over `action` edges. `content::outcomes_reachable_from`
walks those edges through followup pages only, so a "back to the
start" action ends the walk at the front door instead of fanning out.
Templated actions (`/shape/{curve.key}`) resolve to their dynamic
page's pattern. No schema change: the `followup` flag keeps meaning
"not a front door", and the edges were already in the content.
Also: `main.loading`/`main.not-found` gets the same `svh` ladder as
`.hero-piece`, the last bare `vh` in the stylesheet, so wrapper
browsers that resize the webview as their toolbar animates don't
reflow the placeholder.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
Was cropped to a 14rem band via object-fit: cover; now shows the whole
image at natural aspect, bounded at 32rem so a tall one can't tower.
The image deck keeps its fixed-height cards.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
Content-only image control: a markdown image title carries layout —
left/right to float text around it, a number for max-width in rem
(validated, capped) — translated to md-float-* classes and a numeric
inline max-width. No arbitrary CSS reaches the page. Portal also now
serves .avif site assets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
Custom wordmark sizing per site (0.5-6 rem, clamped at render), and
the light-theme invert now applies only to .svg wordmarks - the
white-stroke house style - so a client's raster logo is never
recolored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
Images already flowed through render_inline_markdown ungated; they
now take the same scheme gate as links (https or same-origin only -
no data:, no plain http) and render as full-width framed figures in
alternative, feature, and item-card descriptions. Carries whole-site
imagery for content-driven instances (westra).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
Echo thumbnails float centered along the canvas's bottom edge and the
empty-state line sits in the same band; both are out of flow, so the
widget's height is fixed by the canvas alone whether results come
back or not. The strip is pointer-inert except the thumbnails
themselves, so drawing near the bottom edge still works.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
The icon floated, so a description's second line wrapped back under
it. With an icon the feature is a two-column grid: icon left, every
other child in the text column.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A voice requirement records in the browser and ships PCM to the relay
as one binary frame (voice.js, same mount/stop contract as gesture);
its value becomes {key, digest, duration_ms}. Requirement.value
presets a field and, on a dynamic page, takes the URL segment - so
value: "{key}" tells the voice field where to record. A url resource
source takes the segment too. Resource items with an https `audio`
field render an <audio> player. The gesture widget reports picks to
the relay for ranking.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
action: /shape/{curve.key} fills placeholders from the submitted
responses and must land on a dynamic page (validated). The gesture
widget now treats relay 'place' (kept) and 'echo' (live presence)
frames as pickable options: picking one re-derives the input's key -
that place's decode becomes the ghost under the stroke and the answer
records {key: picked, own_key, selected_from, selected_distance}, the
labelled pair that later ranks places and calibrates the key. Dedupe
by key; an empty state when nothing is kept at the shape.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Alternative and feature descriptions render links, emphasis and code
(pulldown-cmark, html feature only). Block structure flattens to one
paragraph, raw HTML is dropped, link targets are limited to https,
mailto and site-relative paths. A link belongs in the prose, so the
title-link field shipped in 0.3.7 goes before anyone uses it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An https URL on a feature renders its name as an outbound link -
an announcement's programme page, a venue. Validated on load.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A question may carry event: {starts, duration, place}. While the
window is open the page is announced in a strip at the top of every
header (name, when, 'in 3 days'), soonest first, and kept out of the
footer nav; when it closes the page becomes a followup - only a
visitor carrying an answer chain still sees it.
announce.rs keeps one record per event page in the runtime-owned
portal_events bucket (built-in state graph: announced ->
awaiting_summary -> summarized, content may override) and, on a
one-minute idempotent sweep, moves ended windows to awaiting_summary,
publishing the transition on portal.answers.submitted as 'Summary
due' - the post-what-happened task a review desk picks up. Lint
warns when event pages exist but nothing reads portal_events.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
The questions/ tree is the router now: ids derive from file paths
(index.yaml names its directory; explicit id still wins for legacy
content), actions and requires_chain accept relative refs, nested
non-index files infer followup, and _section.yaml applies qualifies/
requires_chain/responsible to everything under its directory. Dynamic
[name].yaml pages serve any /dir/<value> with the segment substituted
into {name} resource-key placeholders; submissions index their chain
node in a portal_chains KV so requires_chain pages can verify a
visitor's ?chain= lineage actually ends at the required question.
Loading uses one recursive git-trees call; question_lint walks
subdirectories the same way. Implements docs/design/filesystem-routes.md.
Also: the YES hero now starts at HTML parse time via an inline module
script (yes.js moved to public/ for a stable /yes.js the wasm binding
raw_module-imports too - snippet paths are per-build-hashed), with
hydration adopting the running instance; and both gesture containers
reserve their box in CSS so mounting doesn't shift content.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
Requirement.bind {field, param, resource}: when the watched sibling
changes (a file select, say), the bind's resource is fetched with the
sibling's value as a parameter and the result becomes this field's
value - the on-site editing flow for /develop-proposal, and a generic
select-plus-detail primitive anywhere else. Url sources gain {name}
path templating (percent-encoded, / preserved, SSRF check runs after
substitution); an empty sibling never fetches and never clears an
edit. Bound textareas render monospace.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Under the title it read as asking for feedback before the visitor had
seen anything - it's meta, it closes the page rather than opening it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An image can't take the text color; a mask-image painted by
background-color can. Icons now follow --feature-accent when the
feature declares a color, and the surrounding text color otherwise.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- The "Asked by X" line moves from the page footer to directly below
the hero, where the question it belongs to is.
- New Question.followup flag: a post-submission page only appears in
the question nav once the visitor's context actually carries an
answer chain - nobody qualifies for "what happens now" before
something happened.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- QuestionNav: every other question the visitor currently qualifies
for, rendered under each page's alternatives - a concern reaches
the people it speaks to without claiming front-page space, and an
owner sees the gated desks in the same nav. Context-dependent by
session (list_qualifying_questions filters via is_qualified).
- An alternative with an action but nothing to submit, confirm, or
record is a gateway: its button now renders as a real link instead
of a form submit that did nothing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
image: Option<String> becomes images: Vec<String> (nothing in live
content used the old field). One url renders as the plain banner it
was; several become a swipeable cards-effect deck via Swiper Element
12.2.0, vendored into public/ (MIT) like prosekit-editor.js/yes.js
rather than pulled from a CDN - the bundle only loads on pages where
some alternative actually declares more than one image.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The report button logged to an event stream nothing ever read - no
notification, no dashboard, just a durable no-op. A browser back
button already covers "this page wasn't helpful" better than a button
that silently does nothing visible to anyone but the visitor who
clicked it.
The mailto address is now assembled from data-user/data-domain on a
real mouse event instead of baked into the server-rendered href -
keeps a plain mailto: string (what bulk scrapers regex HTML for) out
of what a generic bot crawl sees, without hiding the contact option
from an actual visitor.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
image: a banner url rendered as <img>, client-fetched directly (never
server-side, so none of ResourceSource::Url's SSRF concern applies).
color: any CSS color, set as the feature's own --feature-accent custom
property (never interpolated into a stylesheet) - draws a left-border
accent; unset means no border, not a fallback to the global --accent.
icon: an Iconify name (lucide:star etc.), rendered via Iconify's public
SVG API - no icon library bundled, matching this app's zero-JS-
dependency content otherwise keeps to.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Each AnswerRow used to carry its own ServerAction<TransitionAnswer> and
its own "Confirm" button - a page with N open rows showed N buttons.
Selecting a transition now just toggles an entry in a shared
pending_transitions map (keyed by feature_name+item_id) owned by
AlternativeCard; one button, labeled from the alternative's own
consequence field (same mechanism Subscribers' "Send" already used),
confirms every selection at once via the new batch transition_answers.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>