Bergen Facilitation Collective Website
  • Rust 70.7%
  • Python 15%
  • CSS 9.1%
  • HTML 2.8%
  • Nix 1.9%
  • Other 0.5%
Find a file
2026-08-13 23:06:38 +02:00
.dev-flow Allow admins to edit other's profiles 2026-08-11 22:10:43 +02:00
assets Optimize SEO and performance based on lighthouse reports 2026-08-13 09:31:56 +02:00
containers Put the sqlite3 CLI in the server image 2026-08-13 20:26:27 +02:00
crates Stop an item with no id, which was taking out every list 2026-08-13 20:25:19 +02:00
docs Add favicon and try to fix flaky tests 2026-08-12 09:57:09 +02:00
shots Initial commit of design-preview 2026-08-05 22:57:24 +02:00
tests/browser Optimize SEO and performance based on lighthouse reports 2026-08-13 09:31:56 +02:00
tools Initial commit of design-preview 2026-08-05 22:57:24 +02:00
.dockerignore Address bugs.md: hero scaling, offline flapping, steward credits, event times 2026-08-09 19:52:58 +02:00
.env.example Move newsletter to substack 2026-08-10 00:18:12 +02:00
.gitignore Optimize SEO and performance based on lighthouse reports 2026-08-13 09:31:56 +02:00
AGENTS.md Initial commit of design-preview 2026-08-05 22:57:24 +02:00
BFC-profile.pdf Add profile pdf for reference 2026-08-11 10:15:36 +02:00
Cargo.lock Release 0.5.1 2026-08-13 23:06:38 +02:00
Cargo.toml Release 0.5.1 2026-08-13 23:06:38 +02:00
CLAUDE.md Initial commit of design-preview 2026-08-05 22:57:24 +02:00
deploy.sh Adjust deploy tag format 2026-08-11 12:49:47 +02:00
devenv-docs.nix Add first working version with most functions ready 2026-08-09 00:02:27 +02:00
devenv.lock Initial commit of design-preview 2026-08-05 22:57:24 +02:00
devenv.nix Optimize SEO and performance based on lighthouse reports 2026-08-13 09:31:56 +02:00
devenv.yaml Initial commit of design-preview 2026-08-05 22:57:24 +02:00
logo.png Add favicon and try to fix flaky tests 2026-08-12 09:57:09 +02:00
README.md Amend to version 0.2.0 style fixed, tests and documentation 2026-08-11 15:36:26 +02:00
rust-toolchain.toml Initial commit of design-preview 2026-08-05 22:57:24 +02:00
vision.md Finish work for first initial version 2026-08-11 10:17:28 +02:00

bfc

The Bergen Facilitation Collective's website: a bilingual, server-rendered site about a community of practice in Bergen. Norwegian bokmål and English, four public pages, and a poster-composition design.

Built on the local-first-web kickstart template. Its sync machinery has since been replaced by a versioned delta feed over a browser-held mirror — see Local-first.

  • Vision and content brief: vision.md
  • Design direction, and the eleven that were not chosen: .dev-flow/design/
  • Every decision taken while building it, with its reversal condition: .dev-flow/decisions/
  • The long-form documentation: docs/ (mdbook serve docs)

Not ready to go live. The copy is an agent draft, the Norwegian is an unreviewed first pass, and every photograph is a stock placeholder. See What is provisional.

Two tiers, both complete

Without JavaScript the server renders every page and every link and control works. There is nothing inert anywhere in the markup: nothing is disabled and there is no <noscript> explanation to give, because nothing here needs scripting to function. The language switcher is a set of links, the navigation is links, the 404 page is a real page, and search is a GET form that loads a server-rendered results page.

Nothing in the markup changes across the hydration gap. The only observable difference is a data-hydrated attribute on <html>, which exists because the shell is otherwise complete without scripting and there would be no way to tell whether the wasm arrived. No stylesheet selects on it and nothing renders differently because of it — check-browser asserts both.

With JavaScript the same markup hydrates and leptos_router takes over in-page navigation. The browser fills its own copy of the published site in the background and the pages read from it, so a navigation renders without asking the server again and a change published elsewhere appears on an open page.

The site

Four public pages, each in both languages, at /{locale}/{slug}:

Path Page
/en/, /nb/ Home — splits the two audiences
/en/learn, /nb/learn Learn with us — the practitioner audience
/en/work, /nb/work Work with us — the organisational audience
/en/about, /nb/about About BFC — who the collective is

Plus two that are real but are not pages you browse to: /{locale}/search (see Search) and /{locale}/new, which renders what has changed since this browser last looked. /internal and /admin are unprefixed, because member routes have no per-language URL.

Both are noindex, follow and carry no canonical link or hreflang alternates. A results page for an arbitrary query is a crawl trap that duplicates the pages it links to, and what's-new renders one browser's own state, which means nothing to a crawler.

/ names no language, so it answers 303 to a locale home page rather than rendering one. Slugs are English under both prefixes: one route table, one set of canonical URLs, and no way for a translated slug to drift from its route. The locale in the path is the only authority — a lang cookie is a preference that decides where / sends you and nothing else, and a test asserts that a cookie saying en cannot make /nb/learn render in English.

crates/app/src/routes.rs is the single parser and builder of every path. A page without a nav label, title or description does not compile.

Layout

  • crates/app: Leptos components shared by the SSR and hydration builds, one module per component with its *.module.css beside it. routes.rs is the route table; pages.rs is the four pages as lists of sections; sections/ holds the reusable bands; search.rs, search_field.rs and search_results.rs are the search surface; newsletter.rs is where the newsletter is published — one constant, since it moved to Substack — and email.rs holds the address rules the member directory matches on; steward.rs is the steward record, the About section, the byline every publishing feature reuses and the editing form; content.rs is events and offerings — one type with a discriminator — with their public page, their lists, their three states and their authoring form; locales/ and build.rs are the message catalogue
  • crates/domain: shared validation and errors, compiled for both tiers. No user-visible text, and no leptos
  • crates/server: the Axum server — locale negotiation, the / bounce, SSR, the replication feeds, and the test suite that reads the served HTML. cli.rs is the command line and backup.rs the snapshot/verify/restore commands, db.rs is the SQLite connection and the migration runner, search.rs the index and its two query passes, token.rs the one-shot secrets sessions are minted from, stewards.rs the steward records and the photograph pipeline, content.rs the events and offerings store and their authoring handlers, changes.rs the change log and its watermarks, sync.rs the snapshot and delta feeds and the notification socket, and build.rs extracts the page copy from the catalogue at build time
  • crates/client: the wasm hydration entrypoint, the browser mirror and the sync engine — store.rs is both
  • assets/: static files served at /assets — the global app.css, boot.js, the icons/ source SVGs, the committed photos/, the uploaded stewards/ renditions (see BFC_PHOTO_DIR), and the generated components.css, icons.svg, fonts/ and pkg/
  • tools/build_icons.py: bundles assets/icons/ into the sprite
  • tests/browser/: check_shell.py for the site, check_search.py for search on both tiers, check_a11y.py for WCAG 2.2 AA over every route in both tiers, check_auth.py for sign-in and the member-space guard against a real local OIDC provider (mock_oidc.py), check_admin.py for the admin space and the role guards, check_steward.py for steward profiles end to end, check_events.py for authoring and publishing end to end, check_offerings.py for the third state and the manual ordering, check_tiers.py for the machinery underneath them
  • .dev-flow/: the design documents the site was built from — features, architecture, quality gates, task plans and decisions

Commands

serve           # builds assets, font and wasm bundle, then runs the server on :3000
build-assets    # the icon sprite and the component stylesheet, minified
build-fonts     # subsets Inter into assets/fonts/
build-photos    # renders each photograph into four widths, WebP and JPEG
build-client    # just the browser tier, into assets/pkg
check           # fmt, clippy and tests, for the host and the wasm target
check-browser   # drives both tiers in a headless browser
backup-check    # exercises the backup/verify/restore subcommands end to end
docs            # serves the book on :3001
docs-build      # builds the book into docs/book/

Nothing here puts a message on the network. There was a mail-check that sent one real email through an SMTP relay, for the newsletter this project used to host; the newsletter is published on Substack now and there is no mail transport in the tree.

Ports are overridable, and worth knowing about because the failure when they collide is confusing: SERVER_ADDR moves the server, BFC_CHECK_PORT moves the one check-browser uses at both ends, and MDBOOK_PORT moves the book. The book is on 3001 for that reason — a book left running on 3000 used to make check-browser start a server that could not bind and then check the book, so every assertion failed in a way that looked like the site had broken. It now refuses to run when the port is taken, and the server says what is wrong instead of panicking.

check runs clippy on both targets, not just cargo check. The workspace denies a large pedantic lint set, so a passing cargo check says very little about whether the tree will survive the commit hook — and a lint that only fires under --target wasm32-unknown-unknown is invisible to the host pass.

check-browser covers what check structurally cannot, and it earns its keep. The first time it ran against this site it found four defects that no host-side test could reach: <html lang> left stale after a hydrated language switch, a browser replica that was never persisted, and two cases of a decorative shape rendering the text behind it invisible. It runs all eleven scripts in one pass and reports every result, because a failure in one says nothing about the others. Search added four more defects to that tally — a debounce effect that tracked the router's location and so pushed a history entry per keystroke, a stale assertion from the previous feature, a photograph's alt text offered as a search result, and a header that became two rows tall because a field was added to it. The newsletter's own check added one more, and a more interesting one: a panic in about a third of renders, from leptos_i18n's isomorphic cookie effect running after its owner is disposed. It was never the newsletter's — it reproduced with the signup form removed — and it took a check that makes many requests in a row to surface it at all. See What is provisional.

Five things under assets/ are generated and git-ignored, so a fresh checkout has none of them, and every one fails quietly:

Missing What you see
assets/pkg/ the page renders and never hydrates
assets/components.css every component renders unstyled
assets/icons.svg icons render blank
assets/fonts/ the page renders in a fallback face
the derived photographs photographs are missing, or the widest JPEG on a phone

Use serve, which builds all five first. Starting the server with cargo run skips them; it warns at startup for each one that is absent.

The photographs are the one entry that is partly source: assets/photos/*-1600.jpg are committed, because they came from the network rather than from a build, and build-photos derives the other three widths and every WebP from them.

build-client and build-assets each verify a CLI-against-crate version pair — wasm-bindgen and stylance. Both come from the pinned nixpkgs and are pinned in Cargo.toml to match; if a nixpkgs bump moves either, the script prints the one line to change. The stylance pair matters more than it looks: the class-name hash is computed by the crate at compile time and by the CLI at bundle time, and a mismatch produces markup whose class names nothing in the stylesheet defines — an unstyled page with no error anywhere. check_tiers asserts the agreement from the other end, by checking that every class in the served markup is defined in the bundle.

The first build is slow. leptos_i18n_build pulls the ICU datagen stack as a build dependency, which is a few hundred crates; it is compiled once and cached, and sccache is on by default.

Rust flags are set per target, never globally

devenv.nix sets CARGO_TARGET_<triple>_RUSTFLAGS for the host and for wasm32-unknown-unknown separately, and deliberately leaves RUSTFLAGS unset. cargo reads rustflags from exactly one source: RUSTFLAGS outranks every per-target setting and, once present, suppresses them entirely — even when it is the empty string. So a global RUSTFLAGS cannot be combined with per-target flags, and anything in it follows --target wasm32-unknown-unknown too. That is why languages.rust.mold.enable is not used here: it appends -C link-arg=-fuse-ld=mold to that one global variable, and the flag then reaches rust-lld, which fails the wasm link with unknown argument: -fuse-ld=mold. Add host-only flags to the host variable.

Releasing

./deploy.sh              # patch — 0.1.0 → 0.1.1
./deploy.sh mn           # minor — 0.1.0 → 0.2.0
./deploy.sh mj           # major — 0.1.0 → 1.0.0
./deploy.sh mn --dry-run # print every step and perform none of them
./deploy.sh --no-push    # commit, tag and build; push nothing

It moves the version, runs check, commits, tags vX.Y.Z, builds docker.io/hmuendel/bfc under that version and pushes the image and the tag. Run it inside devenv shell — it calls check and containers/build.sh.

It refuses to run on a dirty tree, untracked files included. A release is a claim that a named commit is what an image contains, and containers/build.sh copies assets/ into the image wholesale — so an untracked file in there would be in the artefact and in no commit, which is the one direction that cannot be reconstructed afterwards.

The version is one number. [workspace.package] version in the root Cargo.toml, with version.workspace = true in all four crates: a release is a server binary and the browser bundle it serves, and a browser holding one version of bfc_app against a server holding another is not a state worth being able to describe.

The image is built before the tag is pushed, so a failed build leaves a release nobody else has seen — git tag -d vX.Y.Z && git reset --hard HEAD~1 and nothing else. The mock OIDC provider is not built: it is a test fixture from tests/, in no release artefact, and tagging it with a version would say otherwise. See containers/README.md.

deploy.sh does not run check-browser. That one needs ports, a database and a browser, and holding a release hostage to whether port 3000 happens to be free is a worse trade than running it yourself first.

Internationalization

leptos_i18n, with the catalogue in crates/app/locales/*.json5 and the typed i18n module generated from it by crates/app/build.rs. The check is at compile time, which is the point: t!(i18n, no_such_key) does not compile, and a key missing from a translation is a build warning — before an untranslated string can reach the no-JS tier, where there is no second chance to fix it after paint.

  • JSON5, not JSON, so a message can carry a comment telling the translator that "Close" is a verb here. Switching to plain JSON is one line in build.rs plus a rename, if a translation platform needs it.
  • Messages are whole sentences, never fragments joined in a view: Norwegian orders a sentence differently from English. One key per paragraph.
  • Page titles are whole strings rather than a page name and a site name joined with a dash — the separator, the order, and whether the site name appears at all are translation decisions.
  • aria-label, alt and every caption go through the catalogue too, because a screen reader reads them out. So do domain rejections: domain_error_message maps each DomainError variant to a message exhaustively, so a new variant fails to compile until it has wording.
  • The locale is resolved in exactly one place: the server, from the path. crates/app never negotiates a language. crates/server/src/locale.rs negotiates only for the / bounce; from then on the path decides, Shell renders it into <html lang dir>, and leptos_i18n reads that attribute back on the client rather than deciding a second time.
  • The switcher is plain <a> links to the same page under the other prefix, so it works with scripting disabled and keeps the reader on the page they were reading. It carries rel="external" so that a hydrated click is a full page load: without it the router swapped the content and left <html lang> behind, and a screen reader read Norwegian with English phonemes. Each option is labelled in its own language — the one string deliberately not in the catalogue, see locale_switcher.rs.
  • dir comes from Locale::direction() and the CSS uses logical properties throughout, so adding a right-to-left locale needs no second stylesheet.

Only the plurals formatter is enabled. format_datetime, format_list and format_currency compile ICU data into the wasm bundle, and the browser already ships all of it behind js_sys::Intl; plural rules are the one case that cannot be moved to the call site.

Full-text search over the four pages' copy, in both languages, at /{locale}/search?q=…. docs/src/search.md has the detail; the short version:

  • The index is the site's own copy. crates/server/build.rs parses crates/app/locales/*.json5 at build time and emits every page paragraph into the binary; the server writes them into SQLite at startup. A paragraph added to a page is therefore indexed without anyone listing it, and an edit ships its own index — the binary is the authority for both. 112 passages today.

  • One FTS5 table per locale, because the tokeniser has to differ: English gets the Porter stemmer so facilitating finds facilitation, Norwegian gets none, because Porter's English suffix rules produce non-words in Norwegian. Neither strips diacritics: å is a letter, not a decorated a.

  • Two passes. The query as typed, with a prefix on every token — which is what makes as-you-type work before a word is finished — and, only when that found nothing, the query corrected against the index's own vocabulary. A correction notice therefore appears only when there was nothing else to show, and it names both spellings rather than quietly answering something else.

  • A result is a destination, not a passage, with up to two matching passages under it and the query's terms in <mark>. Twelve rows pointing at /en/learn is a list of matches, not a set of choices.

  • Drafts are indexed, and every query is scoped to the session. A steward writing an event should be able to find it; the index is the only thing that can offer that. What stops it being a leak is that the server resolves which item ids a session may see — sync::visible_items, the same function the snapshot, the delta feed and the corpus are filtered by — and answers against those. A draft is in the index for everyone and returned to nobody but its author, an editor and an administrator, marked so it cannot be mistaken for live. The backend is therefore built per request, never once at startup.

  • Results are grouped into rubrics — pages, events, offerings, people — and each rubric leads with its best-ranked hit, so the ranking still decides what comes first. An event links to that event's page and a steward to their page.

    This replaced a scheme where an item was indexed as a passage belonging to the page that lists it. A search for an event returned "Learn with us"; worse, when the Learn page's own copy matched the same query the event's row was folded into the page's hit and disappeared. Searching for the exact title of a published event returned four static pages and no event, which is what "the search seems off" turned out to mean.

  • A hit lands on what matched. A page hit carries the section's anchor and a client-side navigation scrolls to it and puts the reading cursor there — which is a thing the router has to be told to do, and which the arrival machinery used to actively undo by focusing the <h1>.

  • The same field serves both tiers. It is a GET form: scripting off, it submits and the server renders the results. Hydrated, typing navigates to the same URL — debounced, at least two characters, pushing the first entry and replacing afterwards, so one press of Back returns you to the page you were reading. There is no dropdown, because a dropdown is a second surface with no scriptless equivalent.

One server function answers both paths, so "the scriptless form and the hydrated field return the same results" is a property of the design rather than something to keep testing — and check_search.py asserts it anyway.

Drafts are on the lists too, on the same terms: a steward's own unpublished event appears on Learn with us and on the front page, marked, and a visitor's copy of those pages carries neither the row nor its words. Holding the row is the permission — an item only reaches a browser if the server decided that session may see it — so the list filter is appears_in_list and not an audience check repeated in the view.

The member space

Six routes, all below /internal or /admin, and one page per thing:

Route Who reaches it What is on it
/internal anybody signed in the member home: what you may reach, and nothing else
/internal/profile a steward their own public record
/internal/events an event editor or administrator the events they may edit
/internal/offerings an offering editor or administrator the offerings they may edit
/admin an administrator members and roles
/admin/profiles an administrator every steward record, its order, and show/hide

The footer's third column is the member menu: one entry for a stranger (Member login), and beyond that only what this person's roles make true. Log out is last, and it is a POST form styled to sit among the links — signing out destroys a session, so it may not be a link a prefetcher can follow.

The menu and the guard ask the same question. MemberArea::permits returns an (Action, Scope) pair; the menu filters on it and the route guard checks it, so an entry cannot point at a refusal. The guard never consults the menu — that direction is the dangerous one, and hiding an entry is not what protects a page. A bare Steward is offered their own profile and neither authoring area, because Steward grants no editing and reading it otherwise would be new authority.

/internal stays a page rather than redirecting to whichever area you happen to have: it is where sign-in returns to, and a role-dependent redirect sends two people sharing a bookmark to two different places.

Signing in

/internal and /admin are for the collective; every other route is public and unaffected. Members sign in through OIDC with an account they already have — nobody manages a password and this project never holds one — and any number of providers can be configured:

BFC_OIDC_PROVIDERS=google,entra
BFC_OIDC_GOOGLE_ISSUER=https://accounts.google.com
BFC_OIDC_GOOGLE_CLIENT_ID=BFC_OIDC_GOOGLE_CLIENT_SECRET=BFC_OIDC_GOOGLE_NAME=Google

Everything else about a provider comes from discovery, so nothing provider-specific is in the code. Each needs an OAuth client registered against {origin}/auth/callback — one callback for all of them.

Authenticating is not being a member. A successful sign-in proves an email address and nothing else; there is no self-service registration, and a stranger who authenticates perfectly is refused with no session. Identity is the provider's sub claim, not the email, so an address changing hands is not an account takeover — the email recognises somebody once, on first sign-in, and admits nobody afterwards. Every configured provider must assert email_verified; absent fails closed.

BFC_INITIAL_ADMIN bootstraps the first member and only works while the member table is empty, which is what makes it a bootstrap rather than a standing back door.

Sessions are server-side and opaque — the cookie holds a random token, the database only its hash — because a removed member must lose access immediately and sign-out must really sign out. The replication feeds resolve the session on every call, and answer as an anonymous visitor when it has expired rather than refusing — a stale cookie must not break a page that is otherwise working.

Sign-in and sign-out are POST forms, so both work with scripting disabled.

With no provider configured, serve starts a mock one so the member space can be exercised — it signs in anyone and verifies nothing, prints so on every start, and is not in the binary. It is a provider, not a bypass: the server does real discovery, a real code exchange and real token verification against it. See Signing in.

Roles

Six of them, any combination, granted from /admin without a deploy: Administrator, Steward, Event administrator, Event editor, Offering administrator, Offering editor.

The admin space is one matrix and one save button — rows are people, columns are roles, and the grid is a draft until you press Save. It used to be a form per checkbox, so granting three roles to two people was six round trips and six page loads.

An administrator reaches it from the member menu in the footer; somebody who is not one never sees the entry, and the page refuses them if they type the URL.

The matrix is roles and nothing else. The About-page ordering moved to /admin/profiles, the audit trail moved below the table, and deactivation is gone — barring somebody means deleting them, which is a two-step confirmation naming every item that will lose its byline.

Removing an administrator is a server command

There is no control anywhere in the web UI that revokes Admin or deletes an administrator — not on a colleague's row, not on your own. An administrator's Admin cell renders as a word rather than a checkbox, and both delete routes refuse whatever is posted.

That is not because administrators are permanent. It is because the mis-click is the risk: one wrong checkbox in a grid six columns wide should not cost the collective an administrator. So the operation moved to where it is deliberate:

bfc_server members list
bfc_server members grant   hans@example.org admin
bfc_server members revoke  old-admin@example.org admin
bfc_server members delete  someone@example.org          # describes it
bfc_server members delete  someone@example.org --yes    # does it

Whoever can run that already has shell and database access on the host, so it grants no authority that did not already exist — the same reasoning that makes BFC_INITIAL_ADMIN an environment variable. The last administrator is refused by both surfaces, in the store, because BFC_INITIAL_ADMIN only bootstraps into an empty member table and a site with no administrator needs the database opening by hand. Recovery is two steps: grant Admin to somebody else, then revoke it.

members delete prints what it is about to destroy — the roles, the items that lose a byline by title, the steward profile, the live sessions — and does nothing without --yes. The audit trail is the one thing it keeps: role_change no longer references member and carries the addresses on the entry, so the record of what somebody was granted outlives the account.

Deliberately not hierarchical — Event administrator does not imply Event editor, and nothing implies Steward. Each of those is pinned by a test.

Email administrator and Email editor are gone. The newsletter is published on Substack, so nothing in this product holds a list of addresses or sends to one, and a permission for it guarded nothing.

The two Editor roles are scoped by steward link, so authorisation is not "does this member hold this role" but "does this member hold it for this record":

allows(&roles, Action::EditEvent, Scope::Record { stewarded_by_me, my_own })

allows is the only authorisation decision in the system, it denies by default, and every protected handler calls it with the record — not only the navigation that leads there. check_admin.py posts directly to every endpoint as somebody who should not be allowed, because a control that is merely hidden is one that can be posted to.

Two guards stop a lock-out: the last administrator cannot be revoked or deactivated, by themselves or anybody else, and a deactivated administrator does not count toward the total. Both are checked inside the transaction that makes the change.

Every grant, revoke, addition and deactivation is in an append-only trail with who did it — a revoke is a row, never a deletion — and the most recent entry shows beside each member, because a trail nobody sees is one nobody checks.

Members are deactivated, never deleted, so authorship and steward links stay valid. One consequence is stated rather than engineered around: a published event whose only steward has been removed is editable only by an event administrator.

BFC_INITIAL_ADMIN bootstraps the first administrator and stops working once any member exists — a variable that kept granting would be privilege escalation on every restart. Lock-out recovery goes through the database; the one-liner is in Roles.

If sign-in says "This account cannot sign in here", read the startup log. The bootstrap is inert against a database that already has a member, so changing BFC_INITIAL_ADMIN and signing in as the new address does nothing — the server now says so at startup and lists who is a member. In development the answer is usually rm -rf data/ and start over; data/ is runtime state and is git-ignored.

Stewards

A steward writes their own public introduction, uploads a photograph, and both appear on the About page — with no administrator involved and no deploy. The record holds what a visitor reads, which is the half an email address and a role cannot carry.

The editing form is on /internal/profile, and it is the one thing a member may change about themselves. It posts to five routes:

Route Who What
POST /internal/steward the steward name and both introductions
POST /internal/steward/photo the steward the photograph, multipart/form-data
POST /internal/steward/photo/clear the steward removes it
POST /admin/stewards/position an administrator where they appear on About
POST /admin/stewards/hidden an administrator takes the record off the site, or puts it back

/admin/profiles is where the last two live: every steward record as a set, including the half-written ones somebody has to chase. An administrator can order them and take one down; they cannot edit anybody's words, because EditOwnProfile is scoped to mine and this added no action. Edit is offered on the reader's own row and nowhere else.

Hidden is not retired. A retired offering keeps its page, because the collective merely stopped offering the thing; a profile taken down is a person asking not to be shown, so a hidden record leaves the About page and its own /{locale}/people/{slug} page stops resolving. Bylines survive — who ran an event is a fact about the event. A steward cannot restore a record an administrator hid; their editor says so rather than offering a button that would be refused.

The self-edit is narrow structurally: the endpoint takes no member id, so there is nothing to point at somebody else's record. Every handler asks allows(&roles, Action::EditOwnProfile, Scope::mine()), and an administrator correcting a record uses Scope::Any — the same authorisation model, no new mechanism. check_steward.py posts to each route as a member who holds no Steward role, as a steward who is not an administrator, and signed out.

Both languages are required before a record is public. A page that says lang="en" must not render Norwegian prose, hiding one steward in one language makes the section differ for no reason a visitor can see, and falling back is what the message catalogue already refuses to do. The form says so above two fields that are both on screen at once. This is the answer the two publishing features inherit.

A record that is not complete is invisible on About and still gets a byline, falling back to a name derived from the member's address — never the address itself. An event whose byline disappears because its steward has not written an introduction looks unattributed, which is a worse lie than a plain name. The same asymmetry survives removal: revoking the Steward role or deactivating the member takes them off About and leaves their bylines intact, because a published event that loses its byline is a lie about who ran it.

A photograph becomes two files on upload, never on read — a portrait contained to 600×750 and a square centre crop at 128×128, both JPEG. Uploads are the one new attack surface here, so:

  • the 8 MB limit is checked before any decoder sees the bytes;
  • the dimensions come from the image header, before a pixel is decoded, and anything over 8000 a side is refused — a decompression bomb is a small file;
  • the format is decided by sniffing the bytes, never by the filename or the browser's Content-Type;
  • a photograph of a person always has a description, and saving the words cannot blank one that is already there;
  • the old files are removed only after the new ones are written, so a failed replacement leaves the previous photograph in place.

Filenames carry a digest of the upload, so replacing a photograph is a new URL rather than a cache-busting problem — which is why they are served immutable. A steward with no photograph gets a sage square carrying their initials, in both sizes.

Introductions are indexed as About-page content, so searching for a word somebody wrote about themselves finds the page they are on.

BFC_PHOTO_DIR names the directory the renditions live in, defaulting to assets/stewards. It is configurable for the same reason BFC_DB_PATH is: the checks write real photographs and must not write them into the working tree.

See the Stewards chapter.

What the front page leads with

An Event Admin can mark an event as featured, from a checkbox in the editor that is already there. The home page then shows the featured ones, filled up to three by the next events coming up, with nothing appearing twice.

Three is a floor, not a cap. Marking four shows four — refusing the fourth would be the tool overruling the person. Fewer than three events in total shows fewer, because padding with past events or with offerings is worse than a short list. A featured event that has passed drops off by construction: the schedule already removed it from everything the home page reads, so there is no second rule here to disagree with the first.

Featuring is a permission, not a checkbox. Action::FeatureEvent is granted to Event Admin and nothing else: an Event Editor is scoped to events they steward, and letting that scope reach the front page would make self-promotion a granted permission — the same reasoning that moved steward ordering into the admin space. The control renders only for somebody who holds it, and the handler asks again.

One consequence is worth knowing. An unticked checkbox is absent from a form submission, so "did not tick it" and "was never offered it" are the same bytes — which means a save by an Event Editor must not read the field at all, or fixing a typo would silently unfeature the event. ContentDraft::featured is therefore an Option, and the handler fills it in only when the session may set it.

A reader is told nothing: placement is the whole signal, and there is no badge.

The event schedule

Events order by the date they carry, and the list opens with the next one. What has already happened is one tab away, at /{locale}/learn?events=past, most recent first. Two anchors, so both tabs work with scripting disabled — and once the wasm has landed, switching between them swaps the list without a page load, which is what makes them tabs rather than two pages with a shared look.

It used to be a route of its own, /{locale}/past, and that is exactly what made every switch a navigation. The old URL still resolves: it answers 301 to the tab, because links to it have been shared.

"Past" is strictly before today, and today is the server's. Partitioning a list into upcoming and past is a clock reading, and a client wall-clock is display-only by this project's own rules — so the date travels: on the seed, the snapshot, the catch-up feed and every socket frame, the last because a page left open across midnight gets no other signal. Nothing in the browser reads its own clock, so the server-rendered list and the hydrated one are computed from the same string.

The browser's copy is bounded by a cap, not by forgetting. It holds everything upcoming plus the twenty-four most recent events that have happened, so a device that has mirrored for a year holds no more than one that started today — and the past tab has something to render from, which is the half that makes it a tab at all.

That reverses an earlier rule. Past events used to leave the mirror entirely, which bounded it by making the past list unrenderable in the browser: reaching it was always a request, so it had to be a page. One rule, retained_events, is applied by the server to what it sends and by the browser to what it is still holding, so a page left open across midnight and a browser coming back from a week offline end up with the same list.

The search corpus is the one place past events still do not go: a browser answering its own search offers what it holds, and the server's index keeps everything, so somebody with a connection can still find last year's workshop. The row itself is untouched either way — it stays published and its page keeps resolving, because a link somebody shared must not break on a date.

A date passing appends nothing to the change log. There is no sweep task and no bookkeeping row: the log is what the collective did, and it is what the "what's new" count counts. A Tuesday becoming a Wednesday is not news.

Dates are written the way each language writes them — 1 March 2026, 1. mars 2026 — from the message catalogue rather than from ICU and Intl. One source compiled into both tiers cannot disagree with itself; two libraries can. The <time datetime> attribute keeps the ISO value, which is what it is for.

There is no end date, so a Friday-to-Saturday workshop leaves the upcoming list on the Saturday. See .dev-flow/decisions/event-schedule.md (D2).

Events

The part of the site that changes most often, and the one thing the collective can now change without a deploy. Somebody with an event role writes an event in the member space — headings, text, an image, a date, and who is running it, in both languages — previews it, and publishes.

Route Who What
POST /internal/content/new Event Editor or Admin starts a draft and opens it
POST /internal/content whoever stewards it the words, the date, the stewards
POST /internal/content/image " the image, multipart/form-data
POST /internal/content/publish " publish, or take it back to a draft

A draft is a row on the server, not a document in the browser — which departs from vision.md deliberately, so that a draft survives a lost laptop. state is the whole of what keeps it off the site, and it does so completely: a draft's public URL 404s, it is absent from both lists, and it is not in the search index. Its author previews it at /internal/events/{slug}, which renders the public page's own component — there is no second renderer to drift.

Publishing is refused unless both languages are filled in and an event has a date, the same rule steward records follow. Once published it is the next event on the home page, in the full list on Learn with us (past ones included, after the upcoming), on /{locale}/events/{slug}, and in search — where a result lands on its own entry in the list.

Who may edit what is the scoped check member-roles built: an Event Editor may edit an event they steward and not one they do not; an Event Administrator may edit any. The creator is credited as a steward when they create it, or they would be locked out of their own draft.

Images go through the steward photograph pipeline unchanged — same limits, same guards, same content-addressed names — so backup needed no change at all.

Two things are stated rather than engineered around: last write wins if two people edit one event at once, and dates render as YYYY-MM-DD rather than per-locale. See the Events chapter.

A list row shows the date and not the place. A real street address does not fit the row's first column and overflowed it rather than wrapping, because a place name has few break opportunities. Where an event happens is on its own page, which is where somebody who has decided to come will look for it.

Markdown in bodies

An event's body, an offering's body and a steward's introduction are markdown. Everything shorter — a title, a summary, alt text — is plain text, because those are rendered inside layouts they do not own.

Prose written the way it was always written renders the way it always did. Blank lines separate paragraphs and nothing else changes, so nobody has to be taught anything. An author who does know markdown gets a subset:

## a heading            ### a smaller one
**bold**  *italic*      `code`
- a list                1. a numbered list
> a quotation           ---  a separator
[a link](https://example.org)   [and to a page here](/en/learn)

Not: tables, footnotes, task lists, strikethrough, images, or raw HTML. Those are absent rather than switched off — bfc_app::markdown::options is Options::empty(), so no GFM extension is compiled in. A bare URL is not linked; <https://example.org> in angle brackets is.

Three things are worth knowing about how it is built, all of them in crates/app/src/markdown.rs:

  • No HTML string exists anywhere in the path. parse produces an owned tree and the Markdown component renders it as leptos views, so an author's text becomes a text node — and a text node cannot be markup. There is no sanitiser in this project because there is nothing to sanitise.
  • An unsafe link has no representation. Link holds an http/https URL, a mailto: address, or a root-relative path. A javascript: or data: destination does not become a link at all: the label renders as the words that were typed and no href is produced. External links go through the same component every hand-written outbound link uses, so they carry rel="noopener external" and say that a tab is opening.
  • Headings cannot skip a level. Whatever depth an author starts at becomes h2 under the page's h1, and one level below it becomes h3. There is no h4: the stylesheet styles no deeper, and the type has only two variants.

Search indexes the words, not the punctuation — **important** is findable as "important", and no result snippet shows asterisks or a link target. The browser's copy of the index inherits this, because it is the server's passage table.

The prose look lives in crates/app/src/markdown.module.css and nowhere else. It has more to do than most component stylesheets: the reset zeroes every margin and strips list markers, so that file restores spacing, bullets and indentation rather than adjusting them.

Offerings

The same machinery as events, minus the date. An offering is a title, a summary, a body, an image and the people who run it — written in the same editor, guarded by the same check with Action::EditOffering, previewed by the same component, and its images through the same pipeline. It lands on Work with us, below the three static sections, which stay as page copy.

Three things differ:

  • Somebody sets the order. An integer, ties broken by title. Events ignore it and keep sorting by date.
  • It can be retired, which is not unpublishing. Retiring leaves the list and keeps the page and the search entry — a link somebody shared should not start 404ing because the collective stopped offering the thing. Unpublishing is the other thing: back to a draft, invisible everywhere.
  • Retiring is not refused for a half-written item. Publishing is guarded because it puts something in front of people; refusing to take a broken thing down would be the wrong way round.

Building this was the test of whether event-publishing was designed for two content types, and it passed: no second store, editor, image path, guard, public-page component or indexing path. See the chapter.

Item pages

Every published thing has an address:

/{locale}/events/{slug}       one event
/{locale}/offerings/{slug}    one offering
/{locale}/people/{slug}       one steward

people rather than stewards because "steward" is the collective's word for a role and a URL is read by visitors. A steward's slug is a column, derived once from the display name and never changed — renaming must not move a URL and break a link somebody shared. Two people with one name get a numeric suffix.

A member with rights over an item gets its controls on the item's own page: Edit, Publish or Unpublish, Retire, and Delete. Each is offered only where allows grants it with the item's own scope, and each is checked again in the handler — a control that is only hidden is a control that can be posted to, and check_items.py posts to the deletion directly to prove it is not. They are forms rather than links, so they work with scripting off.

Three ways to take something down, and they are distinguishable on purpose: unpublishing returns it to a draft and its page 404s; retiring takes it off the lists but keeps its page and its index entry, which is what a shared link needs; deleting removes the row, 404s the page, and tells every browser holding a copy to drop it. Deleting a published item goes through a confirmation that says so in words and points at retiring — a real page, because the scriptless tier has no confirm().

A steward withdraws their page by leaving their record incomplete. That one condition already governs the About page and the search index, so it governs the page too; there is no separate hidden flag.

What's new

A returning visitor can see what changed since they last looked, at /{locale}/new, with a count in the header.

There are two watermarks: the sync one, which is the version the browser's mirror holds, and the seen one, which is the version the visitor has looked at. Both live in the browser. Nothing about who read what reaches the server — this feature adds no endpoint, no column and no per-visitor state, and the only request it makes is the delta feed every client already uses, asked from a different version.

Only opening the page advances the seen watermark. Not a page load, not data arriving, not a timer — anything else makes the count clear itself.

Whether something is new or changed is decided against the ids the browser held when the visitor last looked, which are written down beside the seen watermark. That is a better question than the server could answer, and it is why the schema was untouched.

Asking the mirror as it is now answers a different question, and answers it wrong in one direction: the catch-up has already put every one of those rows in, so everything reads as changed. A profile published since the last visit showed up as a change to a profile nobody had ever seen — reported, and fixed by recording the answer while it is still true.

A first visit is quiet — the watermark starts where the server is, so nobody arrives to a badge counting the site's history at them. The header link is shown only when there is something behind it: a standing "What's new" that almost always leads to "nothing has changed" is an invitation to be disappointed. The page still says it, for anybody who arrives by URL or bookmark.

The entries are grouped under the same rubrics the search results use — events, offerings, people — so a reader who has learnt those headings on one page has learnt them on the other.

This is the one page on the public site that needs JavaScript, and it says so. What it counts lives in the browser's own storage, so there is nothing to compute without wasm; the page still renders and explains what it needs rather than appearing broken. (/debug needs it too, for the same reason — see Local-first.)

Writing offline

Nothing typed is lost to a reload, a crash or a lost connection.

Every authoring field is autosaved to the browser as it is typed — never awaited, so typing does not wait for storage — and put back on the next load. Drafts never leave the browser.

It used to restore into empty fields only, on the argument that filling one that already had a saved value would be the autosave undoing a save that worked. The argument is right; the rule drawn from it was not. What it produced was that typing into a field with words already in it — every field of every item that is not brand new — was thrown away by the next reload, which is the case the feature exists for.

What makes overwriting safe is knowing when the words landed. A redirect that accepted them says ?content=saved (a profile says ?steward=saved), the browser clears that form's autosave and takes the marker out of the address so a reload cannot read it twice. Every other outcome leaves the autosave alone, which is what brings the text back into a refused save. Uploading a picture answers a different key on both forms — it saved the picture, not the prose.

A save made with no connection is queued and replays when it returns: serially, in order, stopping at the first refusal. Accepted entries go; refused ones are kept with their text, so nothing an author wrote is dropped to tidy a queue.

Every save carries the version it started from. If somebody else wrote in between it is refused with both versions, and a person resolves it field by field — only the fields that actually differ, with a take-all shortcut. Last write wins is kept for role assignment, where the writers are a handful of admins and the content is a checkbox grid rather than prose.

Rights are checked when a write lands, not when it was queued: a member whose role was revoked does not have last week's queued save applied.

The queue lives in localStorage as well as the mirror, because the mirror's storage has relaxed durability — losing a cache entry costs a re-fetch, losing a queued save costs somebody's writing.

A refusal comes back to the editor, not to the area's list. The list renders no notice, so every outcome of a save used to be silent there — saved, too long, no slug, slug taken, image refused, somebody-wrote-first alike — and the text that had just been refused was two navigations away from the only form that could show it again.

Two parts are thinner than the design: the queued state has no panel in the member space yet, and the conflict resolution screen is built and tested but not yet routed — a refused save redirects to the editor with a notice, and the text is still in the fields.

Search in the browser

Once the index is built, a search is answered without a network round trip — by the server's own engine, not one that agrees with it. The schema and the ranking query are each one constant, used by the migration, the server's search and the browser: two SQL strings in two files is how "the same engine" stops being true, and it would stop silently.

The index is built in memory from GET /api/corpus — filtered like every other read path, so a steward's unpublished item is searchable by them and by nobody else — and thrown away with the tab. No VFS, no persistence: an index that is never stored cannot go stale.

While the notification socket is up the server answers, because its index is the authoritative one and cannot be stale; when the socket is down the browser answers from its own copy. A failed server search also falls through to the local index, because a socket notices a moment after the connection actually goes. There is deliberately no spinner.

The search field is a real GET form and works with no scripting; once hydrated it navigates through the router instead, so pressing Enter does not need the server to be there.

One module carries an FFI shim for SQLite's C API — the only unsafe in the project, which is why the workspace lint is deny with one documented exception rather than forbid.

What a page costs

size
release wasm, before any tuning 20.90 MB
opt-level="z", LTO, one codegen unit, strip 2.63 MB
brotli at build time, served precompressed 0.81 MB

Roughly 26× less than a visitor was downloading, and that is with SQLite added for local search. The debug bundle is stripped too — 93 MB to 7 MB — which is what check-browser downloads on every one of its several hundred page loads.

The wasm is compressed at build time and served from a .br beside it; the rendered HTML is compressed per response. docs/src/performance.md has the per-request work as well, and the two ways compression went wrong on the way.

Photographs are the other half of the weight, and they were the larger one. Each is rendered into four widths in WebP and JPEG, offered through <picture> with the JPEG as the fallback, so a browser fetches exactly one file and it is the one its own viewport needs:

fetched for the home page's photograph
before 209 KB, the widest JPEG, whatever the screen
a phone at 390px 18.6 KB
a desktop at 1680px 138 KB

The band is full-bleed, so the widest file really was going to a 390-pixel screen. Uploaded photographs — steward portraits, an item's image — get the same pair of formats, written by the server at upload time; they are already rendered to the one size their slot uses, so they need no widths.

Backup and restore

One SQLite file and a directory of photographs hold everything the collective cannot recreate. The member directory is the sharp case: a member row that is lost is somebody who can no longer sign in, and the roles and steward profiles hanging off it are the collective's own record of who does what.

bfc_server backup /var/backups/bfc-2026-08-06.sqlite
bfc_server verify /var/backups/bfc-2026-08-06.sqlite
bfc_server restore /var/backups/bfc-2026-08-06.sqlite   # server stopped first

backup is VACUUM INTO: one statement, safe while the server is serving, and the output is a single file with no -wal sidecar to reassemble. It refuses an existing destination, because a backup command that overwrites yesterday's backup can destroy two copies at once.

Since steward-profiles a snapshot is two paths, not one: the database file and a .photos/ directory beside it holding the steward renditions. Copy both. A restore that brought back the database and not the faces would leave every steward row pointing at a file that is not there — an error nowhere, and a broken image found by a visitor. verify reports how many photographs a snapshot holds.

verify opens a snapshot read-only and reports integrity_check, the schema version, the row counts an operator would recognise, and the photograph count. It touches nothing else, so it is safe against the only copy that exists. Run it. A snapshot nobody has read is a hope, not a backup.

restore verifies the snapshot before touching the destination, refuses a database that already holds members unless --force is named, stages the copy beside the destination and renames it into place, and migrates an older snapshot forward. A newer one is refused rather than half-loaded.

The server must be stopped before a restore. This is not detected — a lock file lies after a crash and a PID check is a race — so it is stated in the command's help and printed on every run. The refuse-if-populated default is the real protection.

Scheduling, rotation and off-site copies are deliberately not here: they belong to whatever runs the server, and a backup the server takes of itself onto its own disk is not a backup. A snapshot is also unencrypted and contains every member's address — treat the file accordingly.

See the Backups chapter for what is and is not in a snapshot.

Persistence

The first database in the project, and the schema the next three features build on. crates/server/src/db.rs owns exactly two things: a connection with the pragmas set correctly, and a migration runner.

BFC_DB_PATH=/var/lib/bfc/bfc.sqlite      # default: data/bfc.sqlite
BFC_PHOTO_DIR=/var/lib/bfc/stewards      # default: assets/stewards

BFC_PHOTO_DIR is the second half of the durable state, and the only part of it that is not in the database: the steward renditions. It gets a variable of its own for the same reason the database does — check-browser and backup-check both write real photographs, and neither may write them into the working tree. Whatever it points at is served at /assets/stewards, by a file service of its own rather than by the assets/ one, so a non-default path still works.

The database path is relative to the working directory the server runs from — the same assumption ServeDir::new("assets") makes — and the parent directory is created if it is absent, so a first run does not fail on a missing folder. data/ is git-ignored: it is runtime state, and the index in it is rebuilt from the binary on every startup. The startup log names the file and the passage count, because a relative path resolving somewhere unexpected and an index of zero are both things a deployment gets wrong silently.

A database file is created by whichever account ran the server, so on a machine where two accounts share the tree the second one cannot write it — and SQLite reports that only as "attempt to write a readonly database", at the first write rather than at open. enterShell therefore points BFC_DB_PATH at data-ai/ for the shared agent user, the same split CARGO_TARGET_DIR already gets, and the server names the file and the variable when it cannot use it.

  • SQLite is bundled, not linked against the system library. The FTS5 tokeniser and bm25() are the search's observable output, so the engine is pinned in Cargo.lock where a change to it is a reviewable line rather than a nixpkgs bump. It costs about three seconds of C compilation, once.
  • Migrations are a list and an integer. MIGRATIONS is append-only and PRAGMA user_version records how far it got; each step runs in its own transaction with the version bump inside it, so a failure leaves the database at the last version that fully applied. A database whose version is ahead of the binary is refused at startup rather than operated on.
  • One connection, behind a mutex, reached through spawn_blocking. rusqlite is synchronous, so something has to keep it off the runtime; given that, a pool would protect against contention that cannot be demonstrated on a few hundred rows. Reversing that should follow a measurement, which is why the reasoning is written down in .dev-flow/decisions/content-search.md (D10).

Newsletter

Published on Substack. The footer of every page carries a heading, one sentence saying what the publication is, and a link to it — opened in a new tab, with "opens in a new tab" appended to the link's accessible name in a clipped span, because a tab that arrives unannounced is disorienting for somebody who cannot see it happen. The contact page's hearing about the next one section links to the same place.

It used to be hosted here, and that was most of a feature: double opt-in signup from a footer form, hashed confirmation and unsubscribe tokens, a durable mail outbox with a retrying worker, an SMTP relay, four rate-limit windows, two transactional pages and two mails composed from the message catalogue so a Norwegian subscriber got Norwegian mail. All of it is gone — the subscriber and mail_outbox tables, mail.rs, outbox.rs, ratelimit.rs, the lettre dependency, the SMTP_* and NEWSLETTER_FROM configuration and the mail-check command. Nothing in this repository stores an address, sends a message, or has a mail transport compiled into it.

Two pieces outlived it, because something else needed them:

  • bfc_app::emaillooks_like_email and email_key. The member directory asks the same questions of the same kind of value, and gets them wrong more expensively: an address that does not normalise the way an OIDC claim does is a member who cannot sign in.
  • bfc_server::token — 32 bytes from the OS CSPRNG, base64url-encoded, stored as a SHA-256 hash. Sessions are minted from it, which oidc-auth (D4) chose deliberately rather than growing a second token type.

Design

Four colours from BFC-profile.pdf and nothing else: teal #023332, orange #f79722, bone #f2f2f2, sage #bed3d2. Light only — the brand has no dark counterpart, so color-scheme: light is declared rather than a second palette invented. One derived tint, --teal-soft, for secondary copy at 6.11:1 on bone — and only on bone, since it fails as body text on sage. check_shell reads every computed colour on the page and fails on a fifth; check_a11y computes the contrast of every pair that actually renders.

Brand orange is 1.96:1 on bone and can never be text. Orange appears as a fill carrying teal labels, or as a rule. This is the constraint that shapes most of the design.

The direction is a bauhaus poster: flat colour fields, circles, half-circles and thick bars placed into the same grid as the type. Every page opens with the same hero component under a different arrangement of the same six shapes, so no two pages open with the same picture without four copies of one grid drifting apart.

Two rules the poster has to obey, both enforced by check_shell:

  • A shape may pass behind the headline and nowhere else. Each of the headline's three lines carries its own colour band, which is what makes it legible over anything. Nothing else in the hero has a field, so a teal half-circle behind the lede erases it — which is exactly what shipped until the check caught it. Hit-tested with elementsFromPoint over the text's own line boxes, because the bounding box of a rotated bar covers space the bar does not.
  • A colour band must contain its whole glyph. line-height: 0.9 makes the band shorter than Inter's em box, so on the Norwegian home page the ring of Å landed on the orange band at 1.96:1 and the tail of Ø on the bone ground. Both vanished, in Norwegian only. The bands are padded, and the poster is checked in both languages.

The face is Inter, self-hosted. build-fonts subsets the variable font to the scripts this site renders — 344 KiB to 116 KiB, with the 100900 axis intact, so the @font-face range stays true. Two weights are used, 400 and 500; check_shell fails on a third.

Accessibility

The target is WCAG 2.2 AA, and check_a11y.py holds the site to it on every change: axe-core over the rendered DOM, computed contrast for text and for interactive boundaries, and a keyboard traversal — across every public route, in both languages, on both tiers. 613 assertions.

A violation fails the check. There is no baseline file, because a baseline is a mechanism for letting a violation survive a commit. axe itself is pinned by hash in devenv.nix and comes from the nix store, so the check works offline.

The scriptless tier is audited by blocking the wasm bundle rather than by disabling JavaScript — axe is JavaScript, and so is document.activeElement, so disabling it would leave the tier that most needs checking unable to be checked. Blocking /assets/pkg/* gives the DOM the server sent with the instruments still working, and each such pass asserts data-hydrated is absent so it cannot quietly be auditing the wrong tier. This is why the site has no <noscript>: its content would not render in that context, and a check asserts there is none.

What the feature changed in the product, beyond the gate:

  • Route changes are announced. A client-side navigation updates the title and moves focus to the new page's <h1>. It deliberately does not when the search field navigates as you type — that would take the caret out of the field mid-word.
  • Two live regions, present and empty from the first paint: polite for the result count and for a peer's change arriving, role="alert" for the one real error state.
  • Nothing carries meaning by hue alone. The current-page marker gained a teal edge under its orange bar (the orange alone was 1.99:1), and an invalid email address now produces text rather than only an orange border.
  • prefers-contrast: more and forced-colors: active join the existing prefers-reduced-motion block. Under forced colours the site withdraws rather than restating brand colours, and the three indicators drawn with box-shadow become outline, which that mode actually paints.

Running the gate before implementing anything found six defects in already shipped work — including a 404 page with no <title> at all, and a title that went empty after every client-side navigation because provide_meta_context() was only ever called in the server-only Shell.

A real screen-reader run has not happened, and the docs say so rather than implying otherwise. Automated tooling catches roughly a third of what is wrong; see the Accessibility chapter for the manual-pass checklist and its dated log, with that row deliberately empty.

Styling

Two levels, and only two.

assets/app.css is the global stylesheet: the reset, the design tokens, and typography. It declares the cascade layer order once, at the top, and it is the only file that styles bare elements.

Everything a component looks like lives in a *.module.css next to that component under crates/app/src/, and stylance hashes each class against the file's path and concatenates the modules into assets/components.css. A class name therefore cannot collide across components, and style::poster not existing in the CSS is a compile error rather than a rule that silently does nothing.

  • Plain modern CSS in both: cascade layers, custom properties, nesting, :has(), container queries, logical properties. No preprocessor and no CSS-in-Rust.
  • Layer names are document-wide, so a module writes @layer components { … } and lands in the slot app.css reserved, without either file knowing the other.
  • Sections are containers, not text components. Every section in crates/app/src/sections/ takes children and styles the elements inside it, so a page writes semantic markup and knows no class names. A string passed as a prop is captured when the component renders and would go stale when the locale changes under a client-side navigation; a t!(…) written inside the page is a reactive view and cannot. Where a section must tell two paragraphs apart, it exposes a small named component rather than asking the page for a class.
  • The bundle is a real static file, so a colour change needs no recompile. Leave stylance --watch crates/app running while iterating.

If a rule in app.css names anything other than an element, a token or a document-wide preference, it belongs in a module. That line is what stops the global file from becoming the place all styling ends up.

Icons

Phosphor SVGs, vendored into assets/icons/ and bundled by tools/build_icons.py into one assets/icons.svg sprite. A view references a symbol by id; crates/app/src/icon.rs has the details and assets/icons/README.md covers adding one.

No icon crate. They define a const per icon for all ~9,000, so the whole set enters the compile graph to reach the handful drawn here, and the markup ends up inside the wasm bundle instead of in a file the browser can cache on its own. The 57 sprited here are 22 KiB, under 7 KiB over the wire, fetched once for every page — and nothing in them can drift under a cargo update. Prune what this project does not draw.

The Icon enum carries no accessibility default, deliberately: Icon::Translate.decorative() is aria-hidden for an icon beside visible text, and Icon::Trash.labelled(t_string!(i18n, delete)) is role="img" with a real name for an icon that is the control. A single component with an optional label is how icon-only buttons end up announced as "button". A test in icon.rs fails if Icon and assets/icons/ ever disagree in either direction.

Local-first

The browser holds its own copy of what the site publishes, and the pages read from it. A navigation renders because the data is already there, not because the server was asked again; content published by one member appears on another visitor's open page without them doing anything, signed in or not.

Three routes, and one of them carries no data at all:

GET  /api/snapshot          everything this session may see, plus a watermark
GET  /api/changes?since=N   what changed after N that this session may see
GET  /live       (ws)       {"watermark": 812, "build": "3c5d6df3fe23"}

One sequence, several watermarks. Every write appends to change_log inside its own transaction, and the row is labelled with the broadest visibility it held before or after the change. That label decides who is told; a separate filter decides what they get. So unpublishing an item is public news — every public client is holding a copy and must drop it — while a steward editing a draft moves no number an anonymous visitor can observe.

The socket carries a version number and never a row. That is what lets it be open to anonymous visitors with no authorisation story: there is nothing on it to leak. It also separates liveness from correctness — the socket is how a client learns promptly, the watermark is why it is right. Push and poll are the same call, so a proxy that eats WebSocket upgrades turns the site into a polling client and changes nothing else.

The first hydrated render comes from a seed, not from the browser database. The database is asynchronous and hydration is not, so the page embeds exactly the store the server rendered from, and both tiers build the same Store from it. That is one script where there were five, and it is why /admin hydrates.

Nothing in the browser authorises anything. Rows arrive already filtered, and the roles in the local database decide which controls to draw and nothing else. Every write goes to the server and is checked there against the session, so editing the local store in devtools gains exactly nothing. Signing out wipes what the session entitled — unpublished drafts on a shared machine is the exposure that matters.

The mirror is IndexedDB today. SQLite in the browser is the intended home and sits behind the same boundary; it arrives with local search, which is the feature that actually needs a query. See .dev-flow/decisions/local-first-sync.md (D23).

A profile edit reaches the bylines that name it. An item carries a copy of each steward's record, because a byline has to render without a second lookup on both tiers. The feed sends rows one table at a time, so editing a profile appends a Steward change and nothing else — quite rightly, it changed no event — and the copies embedded in that person's items were left as they were, in the signals and in the stored mirror, which is read back over the seed at every boot. So a renamed steward kept their old name on every event they run, across reloads. The rule now runs in both places from one function, and the alternative — appending an item change per credited item — was rejected because it would announce that all of somebody's events had changed when none had.

Static assets are fetched once

The stylesheet, the components bundle, the icon sprite, the brand face, the photographs and the wasm bundle went out with no Cache-Control at all, so a browser guessed from Last-Modified — roughly a tenth of the file's age, which minutes after a deploy is nothing. Every page load revalidated all of them, and a revalidation with no connection is a failed fetch, which is why the photographs disappeared offline.

They are served for a year, immutable, from a URL that carries the build's version:

/assets/v2c50ad3a2fd99293/app.css
/assets/v2c50ad3a2fd99293/pkg/bfc_client_bg.wasm

A version segment rather than hashed filenames, because the files reference each other with relative URLs that nothing here writes: wasm-bindgen's glue asks for the bundle beside it, boot.js imports ./pkg/bfc_client.js, app.css declares a @font-face. One segment in front of the whole tree makes every one of those resolve under the same version by itself, where renaming would mean rewriting three generated files and keeping a manifest. What it gives up is per-file granularity — a new photograph invalidates the bundle too — which for one stylesheet, one sprite, one face, four photographs and one bundle, all rebuilt together, is a distinction without a difference.

The version is BuildId, which already existed, is already in the seed and already tells a page it is running code the server has replaced. It digests VERSIONED_ASSETS — everything a browser caches — and not GENERATED_ASSETS, which is the startup check's list and names the glue without the wasm: a change inside a function that alters no export leaves the glue byte-identical, and with a year of immutable in front of it that would be a browser running last week's bundle.

A URL whose version this build does not recognise is the previous deploy's. It is served — the file is what the browser asked for — but without the promise, because the bytes at that URL have already changed once.

Uploaded renditions under /assets/stewards/ carry no version and never have: their filenames already hold a digest of their own bytes.

Coming back is as important as going away. The socket reconnects with jittered backoff bounded at fifteen seconds rather than a minute, and two other things shorten it further: any answered request is proof the server is there, so a catch-up or a backstop poll clears the offline notice and reconnects at once; and a tab nobody is looking at stops retrying entirely, reconnecting the moment it is looked at again.

navigator.onLine cannot carry this on its own. It reports whether an interface exists, and the case that actually happens — a server restarted, a deploy, a proxy blinking — never changes it, so the page was left waiting out a timer measuring an outage that was over. A two-hundred-second outage now clears in about eleven seconds; before, the backoff had reached its ceiling and it took up to a minute.

/debug shows one browser its own contents. Every table of the in-memory search index with its rows, a refresh, and a dump of everything as text to paste into an issue. Linked from nowhere, noindex, and not a PublicPage — it is a URL you type. It is not secret either: everything on it was already sent to that browser. When a page is out of date the question is whether the rows are missing or the rendering is, and this is the page that answers it.

Automerge, which the project was scaffolded with, is gone: the requirements that decided it — per-session visibility, server-authorised writes, and manual rather than automatic conflict resolution — are not the ones a CRDT serves. D1 argues it in full.

What is provisional

Everything here is deliberate, recorded, and needs a human before launch.

  • The copy is an agent draft, and six strings invent facts about what the collective offers — two of the three "areas of practice" on the Learn page, what Foundations covers, and the community-event format. vision.md names only "Foundations + modules". A visitor can read any of them as an offering and ask to join it, so do not show the site outside the collective until they are confirmed or cut. Left in deliberately; see .dev-flow/decisions/content-pages.md D1 and D13, and docs/src/provisional.md for the list.
  • The Norwegian is an unreviewed first pass. Idiomatic rather than word-for-word, but facilitation vocabulary is exactly where a non-native pass reads as foreign, and the readers who will notice are the ones BFC most wants. D4.
  • Every photograph is a stock placeholder. Three CC0 or public-domain images from Wikimedia Commons, chosen for composition and nothing else. Each carries data-placeholder="true" and a visible caption saying it is not a photograph of BFC, and a test fails if one is served without them. Provenance and how to replace them: assets/photos/PLACEHOLDER.md. D9.
  • No steward names beyond Torhild and Maren. vision.md names those two and leaves two other lines unfinished; nothing was invented to fill them, and a test keeps it that way. D3.
  • No events. The events band shows an honest empty state rather than sample events with dates someone could act on. event-publishing fills it. D5.
  • The past tab has no page title of its own. It is a view of the Learn page now rather than a route, so it inherits Learn's <title>, description and hreflang alternates. That was the argument for it being a separate page, and it was traded for a switch that does not reload — deliberately, and worth revisiting if the past programme ever becomes something people arrive at from a search engine.
  • No social or contact links. The footer's constants are None until real URLs exist, rather than pointing at plausible guesses. D13.
  • Canonical and hreflang URLs are relative. Correct for local development and wrong at launch, where they must be absolute against the real origin. .dev-flow/decisions/site-shell.md D14.

Not yet implemented

  • Registering interest in future offerings, despite the Learn with us copy inviting it. That is a form, a stored record and a way to reach those people later — a feature of its own, and one nobody has scoped.

  • The newsletter is somebody else's system. It is published on Substack, so the subscriber list, its export and everything sent to it are subject to Substack's terms and are not reachable from here. This repository holds a link and nothing more.

  • Locale-aware date formatting. An event renders its date as YYYY-MM-DD, which is unambiguous in both languages but is not what either would write. It wants js_sys::Intl in the browser and ICU4X on the server, and the two must agree exactly or the text shifts on hydration.

  • A search result names a page, not a section — except for an event. The anchor column is written for events, so a result lands on the event's own entry in the list. The four content pages' sections still have no id to land on; giving them one is a content-pages edit.

  • SQLite in the browser. The mirror is IndexedDB, and every query this foundation makes is "read all" and "write all", so SQLite would earn nothing yet. It arrives with local search, behind the same boundary. Confirmed available: sqlite-wasm-rs compiles with -DSQLITE_ENABLE_FTS5, so the browser can run the server's ranking rather than an approximation of it.

  • A locale-stamped member row. MemberView::last_change is a pre-worded sentence, so a mirrored row goes stale in the other language until it changes again. It wants to be a message key and its parameters, worded by the view.

  • Reconnect and backpressure on the socket. /sync requires a session since oidc-auth, but it does not retry a dropped connection and applies no backpressure.

  • Starting up offline. The replica survives a reload, but the first byte still comes from the server, so a cold start with no network cannot render. That needs a service worker to serve the shell and the wasm bundle from cache.

  • A clean log. leptos_i18n creates an isomorphic effect that reads the locale signal, and on the server it can run after the render's owner has been disposed:

    thread 'tokio-rt-worker' panicked … you tried to access a reactive value
    which was defined at crates/app/src/lib.rs, but it has already been disposed.
    

    This entry understated it until newsletter-signup measured it. It was described here as affecting roughly one in twenty aborted requests, with a run of ordinary completed requests producing none. That is not what happens: twenty ordinary, completed, sequential curl requests to /en/ produce five to seven of these. It went unnoticed because nothing else in the repository made many requests in a row until that feature's browser check did.

    It was not caused by the newsletter — removing the signup form from the footer gave 7 in 20 rather than 5 in 20. It is also not caused by initialising the context by hand; <I18nContextProvider> creates the same effect, and disabling the cookie does not prevent it, only its write.

    tokio catches it per task, so the blast radius is that one effect: the response is already complete and correct when it fires. The cost is a log that will bury a real panic. It cannot be fixed from here — I18nContext's signal is private, so the only workaround is an Owner that is never disposed, which leaks one per request. Reproduce with for i in $(seq 20); do curl -s -o /dev/null localhost:3000/en/; done.