Every outbound fetch - Gitea content at boot and on reload, the raw
asset proxy, the repo-info endpoint, the content-declared url resource
- built its own reqwest client with no timeout and read whole bodies
with .text(). `crate::http` is now the one client (5 s connect, 20 s
per request, 5 redirects) with capped body readers: 2 MB for a url
resource, 8 MB for a Gitea reply, 32 MB for a proxied asset; one byte
over is an error, never a truncated document.
The upload handler read a whole field into memory before comparing it
to the 20 MB cap, and axum's 2 MB default body limit had made that cap
unreachable anyway. It now reads chunk by chunk and refuses the moment
the cap is crossed; the /upload route raises the body limit to match.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
Roles overlap on one aggregate: a project manager prices a change, a
site manager completes it. A need may list the other groups whose
desks hold some of its moves; their buttons count toward reaching
`done_when` and toward the strand check exactly as `handled_by`'s do.
`handled_by` must still hold a desk of its own, so that someone is
answerable for the record.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
A simulation that lives in JSON never meets the engine. needs_replay
takes a simulation's cases and runs them through the code the site
runs: answers::store_answer for the submission, aggregates::transition
for each desk move, against a real JetStream with its event log and
its refusal of undeclared moves. Buckets are then read back by
replaying each record's events and reported per bucket against
`done_when` and `success`. `--report-only` takes the same scorecard
from any JetStream, a live site's included.
First run, 30 cases from a generated contractor site on a throwaway
in-memory NATS: 30 aggregates, 56 transitions accepted, none refused.
The same routes against the earlier machine that skipped a stage: 8
refused, 8 of 12 enquiries left in `open`.
The local-checkout loaders move from the lint binary to `portal::local`
so both tools share them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
Found by simulating a generated site: the owner wrote "they come in,
we go and look at the building, we send a quote", and the generated
machine went open -> quoted. Nothing compared the machine to what the
business said, so it passed. A need may now list its stages as the
business describes them; each must be a state of the bucket and
reachable through the handling group's desk.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
A need may name which of its `done_when` states are a good outcome
for the business ("lost" is finished, "won" is success), checked to be
a subset of `done_when`.
`question_lint --needs-sim <model.json>` writes everything a
simulation of the business needs, resolved the way the runtime
resolves content: each need's path with the choice on every page and
the form that gets filled, each bucket's state machine with every
desk's labelled buttons, and the personas. Portal exports it because
only portal loads content correctly; the simulator (uhhm/portal-trainer)
reads plain JSON.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
A content repo may now state what the business needs the site to do,
apart from how the pages word it. question_lint checks each need
against the pages and aggregates.yaml:
- a path of submissions gets the answer into `lands_in`, within
`max_steps` and a required-field budget (`max_fields`);
- `handled_by` holds a desk over that bucket;
- every `done_when` state is reachable through the moves that desk
(or a self_transition) offers, and no record strands on the way;
- a disabled alternative is named as the reason when it is one;
- `planned: true` reports the gap as a warning instead of failing.
Personas - visitors in their own words - become typed `choice` tasks
(the page's question, its alternatives as the options) written as
JSONL by `--needs-tasks` (`--skim`: headings only). Any external
answering engine returns `{id, choice, confidence}` lines, graded by
`--needs-score [--min-accuracy]`: a misroute is a wording defect. The
engine stays outside this crate on purpose so classifiers and LLMs
can be compared on the same tasks. The runtime never reads the file.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
`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
Fonts hide behind the stylesheet - the browser parses CSS before it
finds them - so the latin Quicksand subset (every page's critical
path) is announced in the shell head; latin-ext/vietnamese stay
unicode-range-gated. The wordmark preloads from the resolved site
config, with a preconnect to its origin when it lives on the content
host (redoal.com's does), opening that TLS session early.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
A content repo can ship a favicon (svg/png/ico) named in site.yaml;
portal serves it at /site/<path> and injects a <link rel=icon> after
the static defaults so it wins. Also serves .ico assets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
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
A content-shipped colour logo (Klingenberg's red tile) was being
inverted to teal in light theme by the .svg heuristic. Invert is now
an explicit site.yaml flag defaulting to house-mark-only; content
logos keep their colours unless they opt in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
A select requirement can now declare a plain options: [A, B, C] list
instead of a resource - each string is both value and label. Restores
the ancestor question format's inline enums (used by the klingenberg
port) without needing a resource endpoint for a fixed list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
A content repo can now ship a CSS file named in site.yaml's
stylesheet field; portal serves it same-origin at /site/<path> and
leptos_meta appends it after the default stylesheet so content rules
win. Plain repo-relative .css path only, validated on load.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
The strings the portal itself says - Asked by, Also worth asking,
Sign in, the not-found page, defaults - translate via a small en/no
table selected by site.yaml's lang, which also sets the html lang
attribute. Content keeps speaking for itself; unknown languages fall
back to English per key.
Also: a scheme-gated markdown image now swallows its alt text instead
of leaking it as stray text - this is the fix for the test that has
been red since 0.3.19 (publish is tag-triggered and does not gate on
the test workflow; caught late).
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
A content-only instance (westra preview) has no review desk and no
Kanidm client; booting no longer demands one. Auth routes answer 503
'sign-in is not configured on this instance'; everything public
renders as usual.
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
On ack (and on picking a place) a glowing copy of the stroke peels
off and converges point-by-point onto the key's decoded path - a
staggered wave from stroke start to end, comet trails, additive on
dark / ink on light, ghost deposited where the light settles. WebGL
point sprites on an overlay canvas; no WebGL or reduced-motion means
exactly the previous behavior. ?relay= query override points widgets
at a local relay for dev.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
Resource-fed cards follow the same convention as every other
description: links live there, sanitized by render_inline_markdown.
Carries the per-recording report link on place pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
A date or place corrected after first announce never reached the
portal_events record, so the followup fired on the stale schedule.
While a record is still in its initial state, content is the source
of truth: differing responses are written back on each sweep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
A type: hidden requirement rendered through the fallback branch, whose
label wrapper shows the field name — so a preset key listed as a second
visible field under the email. Bare hidden input, no row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ