# atproto.nl — website Community website for the AT Protocol community in the Netherlands, for anyone active in NL, Dutch or not. Static-first Astro site, English default, bilingual EN/NL, dark/light, built as AT-Protocol-native as practical. Independent project (not affiliated with any company). Home base on Tangled. (`CLAUDE.md` is a symlink to this file — one source of truth for all agent tools.) **Workspace:** Notes, research, and decisions for atproto Europe live in the Obsidian vault at `~/Documents/CoreNotes/Workspaces/atproto-europe`, not in this repo. Terminal alias: `cc-atprotoeu`. ## Tech Stack | Component | Technology | |-------------|------------| | Framework | Astro (static output + Node adapter for on-demand routes) | | Language | TypeScript (strict, no `any`, no `@ts-ignore`) | | Styling | Tailwind (v4) + CSS custom-property tokens; no inline styles | | Design | Handover from Claude Design pending — theme tokens are placeholders | | i18n | Astro i18n (`en` default at `/`, `nl` under `/nl/`); strings in `src/i18n/ui.ts` | | Theming | `color-scheme: light dark` + `light-dark()`; two-state toggle, system default | | Testing | Vitest (+ axe for a11y) | | atproto | `@atproto/api` behind a service layer | ## Coding Standards 1. **TDD** — write tests before implementation (Vitest). Aim >=80% coverage. 2. **Strict TypeScript** — explicit types, no `any`, no `@ts-ignore`. 3. **ES modules**, built-in `fetch`, async/await over Promise chains. 4. **Path aliases**, named exports, components PascalCase / vars camelCase. 5. **Conventional commits** — `type(scope): description`. 6. **Input validation** (Zod) on external input; **sanitize** user-generated / atproto content before render. 7. **Pin exact versions** — no `^`/`~` (tighten before release). 8. **CI must pass** — lint, typecheck, tests, a11y, content + trademark + copy scans. ## Astro - **Fully static.** `output: 'static'`, no server/on-demand routes. Reads happen at build time (public, no auth); all atproto **writes** run client-side via the visitor's OAuth session. (Superseded the earlier plan to use `prerender = false` write routes — see "As-built patterns".) - Small components (~150 lines), composable. - Client islands (`client:*`) are the exception — justify with a comment. ## Accessibility & SEO - **WCAG 2.2 AA**, semantic HTML + ARIA, axe in the test suite, **light and dark** both tested. - Mobile-first (build at 375px). Optimize images, validate content in CI. ## atproto - Users own their data on their own PDS; identity is portable via DIDs — no lock-in. - **Never call `@atproto/api` directly from components/pages** — use a service layer. - **Reference DIDs internally; resolve handles at display time.** Never persist `*.atproto.nl` (or any) handle strings as identity keys. - Events use the standard `community.lexicon.calendar.*`. Membership/forum lexicon: TBD. ## Trademark compliance (public-facing text — our own copy only) Comply with the AT Protocol trademark policy. A `check-trademark` CI lint runs over text we author (pages, `src/i18n/ui.ts`, meta/OG, README) — NOT over ingested atproto content we don't control (posts/profiles/events from other DIDs); CI must not fail on that. - **Approved:** `AT Protocol`, `atproto`, `atprotocol`. - **Flag/fail:** `at-proto`, `AtProto`, `AT-Protocol`, `atProto`, `Atproto`; version/fork terms (`AT Protocol v2`, `atproto-next`, `official AT Protocol fork`); official-status phrases (`Official AT Protocol`, `AT Protocol Certified`, `Certified/endorsed by Bluesky`); concatenated brand forms (`Atproto[A-Z]`). - Allow-list code identifiers, package names, DIDs, and the `atproto.nl` domain literal. - Keep our own community name at least as prominent as the mark; don't imply official status. ## Copy scan (voice / AI-writing — our own copy only) A `check-copy` CI scan checks our authored copy for AI-writing tells. Same own-text-only scope as the trademark lint. ## Domain note We use `atproto.nl` with permission (we don't own it). **Never route email, OAuth, or account recovery through the domain** — nothing we can't re-home should depend on it. ## Git Workflow - Home base is **Tangled** (`tangled.org/atcommons.eu/website`), not GitHub. It is the single source of truth; deploy pushes go to Tangled only (see the Deploy section). - Never commit directly to the default branch. Branch/worktree per change: `type/short-description`. Extensive changes: short PRD first, work in a worktree. ## Local dev - `npm run dev` — dev server at `localhost:4321`. - `npm run build` — production build to `./dist/`. - `npm run preview` — preview the build. - `npm run astro -- check` — typecheck. - Docs: (routing, components, content collections, styling, i18n). ## As-built patterns & defaults Concrete conventions this codebase actually follows. When in doubt, match these. ### Deploy (Tangled Spindle CI → Codeberg Pages) **Tangled is the single source of truth; deploy = push `main` to Tangled.** A push to `main` triggers the **Tangled Spindle** pipeline in `.tangled/workflows/deploy.yml`. It is **publish-only**: it runs `npm ci`, then builds **every scope from `src/config/scopes.ts`** (iterating `node scripts/gen-scopes.mjs --list`, so the deploy set is never hand-listed; `SITE_SCOPE=`; `SCOPES.md` is the generated human-readable table, kept in sync by `npm run gen:scopes` + a vitest gate) and force-pushes each `dist/` to its own Codeberg **Pages** repo (`atproto-website`, `pages` branch), which Codeberg Pages serves at the matching domain. No manual step: push `main` and it deploys. **Quality gates do NOT gate the publish** (decoupled 2026-09-22; see the workspace decision `2026-09-22-deploy-pipeline-hardening.md`). Coupling them was the #1 failure: one banned character or flaky test aborted the run and froze all 12 live sites silently. Tests + copy-voice now run pre-merge — the **`.husky/pre-push`** hook (the real enforcement) and the **`checks.yml`** PR workflow (advisory until Tangled exposes required checks). `deploy.yml` still runs them as a **non-blocking advisory step** that only reports; a hit never withholds a publish (fix forward). Every network op retries, each build is timeout-bounded, a **collapsed-content guard** refuses to force-push a blank homepage over a good one, and on any per-scope failure the job POSTs to the optional `deploy_alert_url` secret and exits non-zero. Each `pages` branch carries a `.deployed-sha` marker (the source commit that built it) for a future scheduled reconciler. **Push to Tangled ONLY. Never push the Codeberg source mirror.** `origin` is Tangled for both fetch and push (a single push URL). Writing to the Codeberg *source* mirror (`atprotonl-website` main) is what caused the 2026-09-22 freeze: it and Tangled `main` diverged two ways, so neither push fast-forwarded and the Spindle stopped firing for ~11h. That mirror is now abandoned/frozen; nothing reads or writes it. Push with: ```sh git push origin main # origin is Tangled-only git push tangled.org:did:plc:aqrkn66k2alnwxfrhstp3unq/website HEAD:main # explicit, from a worktree ``` `git remote -v` should show a SINGLE `origin` push URL, the Tangled one. If a `codeberg.org/gxjansen/atprotonl-website` push URL ever reappears on `origin`, remove it: `git remote set-url --delete --push origin codeberg.org:gxjansen/atprotonl-website.git`. **Stats refresh (NAS, every 6h) is Tangled-only too.** The scheduled NAS job (`scripts/nl-stats-publish.sh`) clones **Tangled**, regenerates the stats/facts JSON, commits, and pushes `main` to **Tangled only**, which retriggers the Spindle so fresh stats (and new members, enumerated live at build) reach every site. It reads its own script + python helpers from **Tangled raw** with `curl -fsSL` (the `-L` matters: Tangled raw 302-redirects to its blob backend). It never touches Codeberg source. **Spindle prerequisites** (set once in the Tangled repo settings; if a push doesn't deploy, check these first): - A **Spindle attached** via the repo **Pipelines** tab (hosted tangled.org spindle). Nothing runs without one. - A repo **secret named `atprotocc_sites`** = a Codeberg token with `write:repository` on all the `atproto*-website` **Pages** repos. Tangled injects it as an env var of the same name; the workflow reads `$atprotocc_sites` (Tangled rejects `-` in secret names, hence the underscore). - **Optional** repo secret **`deploy_alert_url`** = a webhook that turns a POST body into an alert (e.g. an ntfy topic URL). If set, a failed deploy POSTs the stale scopes there so a freeze is not silent; if unset, failures still log to the run. Never use a channel on a borrowed `atproto.*` domain. **When it breaks — where to look:** - Read a run's log: `ssh -t -p 3333 tangled.org did:plc:fekl5h677qdyo5e4dpl3c4hl ` — a TUI that needs a REAL terminal (it does not scrape headless), or the web **Pipelines** tab. The push output prints this exact command. - Verify what actually deployed: each Codeberg `pages` branch's latest commit is authored `atproto.nl deploy`, message `deploy `. Check: `curl -s "https://codeberg.org/api/v1/repos/gxjansen/atproto-website/commits?sha=pages&limit=1"`. Author `nl-stats deploy` instead = an old direct deploy from the scheduled stats job (it no longer publishes pages; only the Spindle does). - Failure shape: `npm ci` is the only early step that can fail the run now (tests + copy-voice are advisory and never block). A red run therefore points at the deploy step: a missing `atprotocc_sites` secret (aborts before any scope publishes), or a per-scope build/guard/push failure (the job names the stale scopes in the `deploy_alert_url` alert + the log and exits non-zero; the other scopes still published). A transient early failure — re-trigger with an empty commit: `git commit --allow-empty -m "ci: re-trigger" && git push origin HEAD:main`. **Manual fallback (always works — Spindle down, token expired, urgent hotfix):** from a Mac checkout at the target commit, build+push each scope to its Pages repo via the `codeberg*` SSH remotes (full account access, no Spindle or token needed). Loop over the scopes in `src/config/scopes.ts` (the source of truth); as of 2026-09-22: `for s in $(node scripts/gen-scopes.mjs --list); do SITE_SCOPE=$s bash scripts/deploy-codeberg.sh; done` (add the `codeberg-` SSH remote first if missing; the script prints the exact `git remote add`). **Notes:** - **Every scope redeploys on every push, by design.** The pages fetch live data at build time (members, builders via sifa.id, feeds, time-filtered events), so their output drifts run-to-run and a content-diff almost never skips, so we don't bother diffing, just publish them all (~2-min run vs the hosted spindle's 15-min cap). Builds ARE deterministic (`src/lib/seeded-shuffle.ts` seeds the list shuffles) so the display order stays stable between deploys. - A **scheduled 6h stats job** (`scripts/nl-stats-publish.sh`, run on the NAS, git author `nl-stats deploy`) clones **Tangled**, refreshes the stats JSON, commits it, and pushes `main` to **Tangled only**; that push retriggers the Spindle, which republishes with the fresh stats. It does not deploy pages itself (the Spindle is the sole publisher), and it never writes the Codeberg source mirror. (History: it used to push both remotes; the two `main`s diverged and froze the deploy for ~11h on 2026-09-22, which is why it is Tangled-only now.) - Custom domain per scope needs the A/AAAA + `_git-pages-repository` TXT **and** the Codeberg webhook (the webhook is the piece people forget; without it Pages/TLS never updates). - The Tangled SSH key is passphrase-protected but in the macOS keychain — a plain `git push` loads it via `UseKeychain`. **Never** run an explicit `ssh-add` (that forces a passphrase prompt). ### Pages & i18n - Shared body components in `src/components/pages/*Page.astro` take a `lang` prop; route files (`src/pages/**`) are thin `` wrappers. NL lives under `/nl/`. - Every user-facing string goes in `src/i18n/ui.ts` with **both** `en` and `nl` keys. ### Client-side actions (like / repost / follow / subscribe / RSVP) - One delegated handler per action in `src/components/*Actions.astro`, mounted once in `Base.astro`. Buttons carry `data-*` (uri/cid/did/url/labels); the handler uses `window.atn.agent`. - **Graceful fallback is the rule:** logged out **or any write failure** → open the canonical page (mu.social profile/post, atmo.rsvp event) in a new tab. Never show a login-wall or raw error. **Reply and quote always redirect** (no native path). - On `atn:signedin`, each handler's `refresh()` reflects existing state (liked/following/ subscribed/going) and stores the record rkey so the button can toggle off. ### OAuth scopes (handle with care — a bad scope breaks login for everyone) - Single source: `src/lib/oauth-scope.ts` feeds both the dev loopback `client_id` and the hosted `/client-metadata.json`. Requested scope therefore always equals declared metadata. - `repo:?action=create|delete` for record writes; `rpc:?aud=*` for RPC calls (preferences / saved-feeds live on the PDS via `app.bsky.actor.get|putPreferences`). - Adding a scope means existing sessions lack it → users hit the graceful re-login prompt once; fresh logins get it automatically. - **Always PAR-verify** a scope change against `bsky.social` and `eurosky.social` before trusting it: POST a Pushed Authorization Request (endpoint from `/.well-known/oauth-authorization-server`) with the full scope; a `request_uri` back = safe, `invalid_scope` = would break login. ### Build-time data (all public, no auth) - Members: list/profiles + Sifa enrichment (`getSifaInfo`; positions honor the `hidden` flag). - Homepage feed: a curated custom feed generator via `getFeed` (`src/data/feed.ts`). - Events: `community.lexicon.calendar.event` records on the atproto.nl account (`src/data/events-account.ts`) — **publishing an event there makes it appear on the next build** — merged with hardcoded ecosystem events (`src/data/linked-events.ts`). RSVP → atmo.rsvp. - **Sifa is rate-limited (60/min):** never `Promise.all` a burst; use bounded `mapLimit` + a retry-on-429 backoff (see `getSifaInfo` + `home.ts`). Trades build time for complete data — a throttled build silently drops locations/roles (the homepage map vanishes). ### Formatting defaults - Numbers: **nl-NL** (dot thousands, comma decimals) on *both* language sides — NL community site. - Dates: post dates `DD-MM-YYYY`; the homepage feed shows **relative age** ("3d ago"), computed **client-side** (a static build would freeze it) with the absolute date as the no-JS fallback/`title`. Event dates use "25 Jun 2026" (day, short month, year); calendar events append a 24h time range. - Preserve line breaks in ingested text with `white-space: pre-line`. - **No em dashes** (`—`) in our copy — use a colon or comma. ### Links, a11y, privacy - Profile/post links go to **mu.social**, never bsky.app — enforced by `src/profile-links.test.ts`. - a11y gate: `e2e/a11y.spec.ts` (Playwright + axe, light **and** dark) must pass; muted text ≥ 4.5:1. - Analytics: cookieless self-hosted Umami, documented on `/privacy`. - **No private infra anywhere public** (repo, commits, issues): no host names/IPs/paths. A public atproto fact like the PDS host (`eurosky.social`) is fine. **No AI attribution in git artifacts.**