Guide for AI coding agents (Claude Code, Cursor, Copilot, etc.) working in this repo.
Scope: the non-obvious architectural facts you need to change code safely. For content authoring (adding a paper, author, talk, blog post) read CONTRIBUTING.md; for the product overview read README.md.
-
Canonical repo:
github.com/protocol/plrd.org. The project moved here fromdaviddao/plrd.org(earlierplresearch.org). All work — branches, PRs, pushes — targetsprotocol/plrd.org. -
Stack: Next.js 15 App Router + React 19 + Tailwind v4 CSS-first + TypeScript. No ESLint, no test runner. Verify with
npx tsc --noEmitandnpm run build. -
Content pipeline: Markdown in
content/→scripts/build-content.mjs(prebuild) → JSON insrc/data/generated/→ typed arrays insrc/lib/content.ts→ pages. JSON is checked into git. -
Trailing slashes are mandatory on every internal
href.skipTrailingSlashRedirect: truemeans missing slashes 404 silently. -
Path alias:
@/*→./src/*. Never use relative imports acrosssrc/. -
Dynamic route params are Promises in Next 15:
const { slug } = await params. -
Editable pages are stored as ATProto records on
plresearch.org's PDS and read through a GraphQL indexer. Static markdown + ATProto + hand-curated JSON coexist. -
FA2 (
/areas/economies-governance/) is hardcoded and special-cased. Don't try to unify it with the genericareas/[slug]/template.
npm run dev |
Runs build-content.mjs, then next dev. |
npm run build |
Runs build-content.mjs, then next build. |
npm start |
Production server. |
npx tsc --noEmit |
The only lint/test available. Run before committing. |
node scripts/build-content.mjs |
Regenerate src/data/generated/*.json without starting Next. |
node scripts/generate-jwk.js |
Emit a new ES256 JWK for ATPROTO_JWK_PRIVATE. |
node scripts/create-publication.mjs |
One-shot: create the site.standard.publication record and print its AT-URI for NEXT_PUBLIC_PUBLICATION_URI. |
node scripts/sync-pages.mjs [--dry-run] |
Patch specific section fields of live org.plresearch.page records from data/atproto/pages/*.json. Read §6 before editing page copy. |
The dev server does not watch content/ — edits to markdown require restarting npm run dev.
After branch switches or content-script changes, rm -rf .next if pages show stale data.
content/ Markdown sources (publications, authors, talks, blog, areas, tutorials, outreach, sections)
scripts/
build-content.mjs Prebuild: Markdown → JSON + RSS + search index
generate-jwk.js ATProto confidential-client JWK generator
create-publication.mjs Creates the site.standard.publication record
sync-pages.mjs Surgically patches live PDS page records from data/atproto/pages/*.json (see §6)
src/
app/ Next.js App Router (see §4)
api/ Route handlers (ATProto OAuth, page CRUD, posts)
.well-known/ AT-URI discovery
areas/economies-governance/ HARDCODED FA2 tree (see §7)
admin/ edit/ write/ Client-only admin UI (requires ATProto auth)
components/ UI primitives (see §5)
data/
generated/ ⚠ auto-generated JSON, checked into git
fa2/ Hand-curated FA2 JSON (projects, opportunity spaces, impact)
lib/
content.ts Typed re-exports of generated JSON
site-config.ts Nav + site metadata
format.ts formatDate, stripFaPrefix, slugToName
env.ts envalid-validated env (COOKIE_SECRET, ATPROTO_*, PUBLIC_URL)
lexicons.ts ATProto collection IDs + ADMIN_DIDS whitelist
tid.ts ATProto record-key generator (TID.nextStr())
indexer.ts GraphQL client for the Railway-hosted indexer
agent.ts Server-side OAuth agent restoration
atproto.tsx Client AuthProvider + useAuth (isAdmin)
atproto-client.ts Unauthenticated Bluesky helpers
session.ts iron-session cookie config
auth/ NodeOAuthClient singleton, state/session stores, JWKS
public/
icons/<name>.svg Raw SVGs, referenced as /icons/<name>.svg
images/authors/<slug>/ Author avatars (avatar.*, first match wins)
feed.xml search-index.json Auto-generated by build-content.mjs
- Scans
content/<section>/. Each subdirectory withindex.mdor_index.mdis a record; loose*.mdfiles in a section also work (slug = filename). - Slug = folder or filename — frontmatter cannot override it. Renaming the folder changes the URL.
- Per-collection mappers (
buildPublications,buildAuthors, etc.) define a fixed shape; unknown frontmatter keys are dropped silently. Adding a field needs edits in both the mapper and theTypeinsrc/lib/content.ts. - Sorting: publications / talks / blog by
datedesc; authors bynameasc (localeCompare); outreach by numericweightasc; tutorials + areas use readdir order. unaffiliated: trueon a publication drops it from the site, RSS, and search index.- Avatars:
buildAuthorsprobespublic/images/authors/<slug>/for any file starting withavatar. Frontmatterresources:is decorative and ignored. - Dependency-graph data:
content/areas/economies-governance/dependency-graph/*.mdparsesbottlenecks/gates/strands/interventions/feedbackLoopsarrays; per-nodetooltipfields are hoisted into a siblingtooltipsmap keyed by node id. - RSS: top 20 publications + top 10 talks, capped at 50, written to
public/feed.xml. - Search index: flattens publications, talks, authors, blog, tutorials, areas plus a hard-coded list of static pages (About, Team, FA2 sub-pages, etc.). Add new static pages to
buildSearchIndexexplicitly.
publications.json, authors.json, talks.json, tutorials.json, blog.json, areas.json, sections.json, dependency-graph.json, outreach.json.
All checked into git — Vercel needs them at build time. Commit regenerated files alongside content edits; a PR with stale JSON ships stale data.
outreach.jsonis generated but not consumed — outreach pages render from markdown elsewhere. Don't rely on it without wiring it up.
Pages must import { publications, authors, ... } from '@/lib/content'. Never read src/data/generated/*.json directly; never parse markdown at runtime.
remark-htmlruns withsanitize: false— raw HTML in markdown passes through and reaches the DOM viadangerouslySetInnerHTML.- Hugo shortcodes are not expanded.
{{< youtube ID >}}survives into HTML;src/app/talks/[slug]/page.tsxregex-extracts the ID and replaces it with an iframe. No other shortcodes are handled — authors must use plain HTML/Markdown. authors[]mixes author-folder slugs (internal) with quoted free-text (externals). Lookups must fall back to rendering the raw string.
src/app/layout.tsx is the only root layout: loads Inter + Newsreader fonts, sets default metadata from siteConfig, wraps children in <AuthProvider> then <SiteShell>.
Detail-page template (follow exactly — params is a Promise in Next 15):
type Props = { params: Promise<{ slug: string }> }
export function generateStaticParams() {
return items.map(i => ({ slug: i.slug }))
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const item = items.find(i => i.slug === slug)
if (!item) return { title: 'Not Found' }
return { title: item.title, description: item.summary }
}
export default async function Page({ params }: Props) {
const { slug } = await params
const item = items.find(i => i.slug === slug)
if (!item) notFound()
// ...
}- Dynamic segments are statically rendered only for items returned by
generateStaticParams. Adding a record requires content files — you can't hand-editsrc/lib/content.ts, it's regenerated. - Use
notFound()inside the default export; return{ title: 'Not Found' }fromgenerateMetadata(don't throw). - Some pages also fetch editable CMS content via
fetchPage(rkey)from@/lib/indexerand fall back to hardcoded defaults when the indexer is unreachable. Seesrc/app/page.tsx,areas/[slug]/page.tsx.
trailingSlash: true+skipTrailingSlashRedirect: trueinnext.config.ts→ every internalhrefmust end with/. No auto-redirect.- Path alias
@/*→./src/*. Grep diffs for relative imports acrosssrc/. - Redirects live in
next.config.ts:/areas/upgrade-economies-governance/*→/areas/economies-governance/*/research/:path*→/insights/:path*(don't add new pages undersrc/app/research/)
images.unoptimized: true: do not rely onnext/imageresizing; the codebase mostly uses raw<img>.
src/app/api/*/route.ts— all markedexport const dynamic = "force-dynamic".src/app/.well-known/site.standard.publication/route.ts— AT-URI discovery (requiresNEXT_PUBLIC_PUBLICATION_URI)./admin,/edit,/write,/about/edit,/areas/[slug]/edit,/areas/economies-governance/edit— client components requiring ATProto auth. Not statically exportable.
- Create
src/app/<route>/page.tsx(server component unless you need hooks/auth). - If markdown-backed: add source under
content/<section>/and extend bothscripts/build-content.mjs(mapper) andsrc/lib/content.ts(type + re-export). - For
[slug]pages: implementgenerateStaticParamsandgenerateMetadata. - Add a
<Breadcrumb items={...}/>above the page body. - Wire nav via
src/lib/site-config.ts(mainNav/footerNav) — pages are not auto-indexed. - If it should be searchable, append it to
buildSearchIndexinscripts/build-content.mjs. - If CMS-editable, add an
rkeytoEDIT_ROUTESinsrc/components/EditPageButton.tsx, render<EditPageButton rkey=".../>, and add a matching/editroute usinguseRequireAdmin+usePageEdit(rkey). - Link it as
/your-route/(trailing slash).
SiteShell (src/components/SiteShell.tsx, client) renders SiteHeader + OffCanvasNav (mobile drawer) + main + optional SiteFooter. Routes matching FULLSCREEN_PATTERNS (currently the dependency-graph slug page) render full-screen with no footer and a scroll lock on body.
Nav structure: src/lib/site-config.ts (mainNav, footerNav, siteConfig). Items with url: '#' are dropdown-only triggers.
| Component | Purpose |
|---|---|
SiteShell / SiteHeader / SiteFooter / OffCanvasNav |
Layout chrome |
NavItem |
Header link with optional dropdown |
SearchBar |
Fuse.js over /search-index.json, / keyboard shortcut |
AuthorCard |
Author chip in 3 sizes (default / lead / advisor) linking to /authors/:slug |
Breadcrumb |
Crumb list; Home auto-prepended |
AreaIcons |
Animated inline SVGs for focus areas (shield/hexagon/neural/brain) |
GeoIllustration |
Seeded deterministic animated SVG placeholder |
FundingPipeline |
Interactive 4-stage grant pipeline card (FA2) |
EditPageButton |
Floating "Edit page" FAB, admin-only |
InlineEdit |
Exports useRequireAdmin, usePageEdit, EditableField, EditBar, EditBarSpacer |
MarkdownEditor |
Simple textarea + homemade-regex preview |
PageEditor |
Full list→section editor backing /admin |
Server components in this layer: SiteFooter, AuthorCard, Breadcrumb, AreaIcons, GeoIllustration. Everything else is 'use client'.
- No
tailwind.config.js. Theme tokens live in an@theme { ... }block insidesrc/app/globals.css. PostCSS uses only@tailwindcss/postcss. - Brand tokens:
--color-blue(#1982F4),--color-pink(#E51A66),--color-teal,--color-black(#131316).text-blue/bg-blue/90resolve to the brand color, not Tailwind'sblue-500. - Custom font sizes override Tailwind defaults (
text-xl= 48px,text-lg= 32px). Be deliberate with size classes. - Breakpoints:
sm/md/lgdefault,xl= 1146px. - Common patterns:
max-w-6xl mx-auto px-6page container,rounded-fullpill buttons,bg-white/95 backdrop-blur-smsticky surfaces,@utility scrollbar-hide. - Prose rules for rendered Markdown live under
.page-contentand.page-topinglobals.css.
- Icons:
public/icons/<name>.svg, referenced as<img src="/icons/<name>.svg">. Typos fail silently (broken image). Social icons includegithub,twitter,linkedin,orcid,google-scholar,arxiv,doi,acm,elsevier,researchgate, etc.; chrome includeschevron-*,menu,search. - Author avatars:
authors.find(...).avatarPathisstring | null;AuthorCardfalls back to an initial badge. next/imageis not used here. Image optimization is disabled project-wide.
formatDate(str) → "Jan 5, 2026" via Intl.toLocaleDateString.
stripFaPrefix(title) removes FA\d+:\s*.
slugToName(slug) title-cases a dash-separated slug.
Editable pages (landing, about, focus areas, etc.) are stored as ATProto records on plresearch.org's PDS, served through a Railway-hosted GraphQL indexer, and rendered server-side with static fallbacks.
src/lib/auth/client.ts flips between two modes based on env:
| Mode | Trigger | Use case |
|---|---|---|
| Confidential client | PUBLIC_URL and ATPROTO_JWK_PRIVATE set |
Production. private_key_jwt (ES256) + JWKS endpoint. |
| Public client | Either missing | Local dev. client_id uses 127.0.0.1, no client auth. |
OAuth callback must use 127.0.0.1, not localhost, per RFC 8252.
POST /api/loginwith handle →client.authorize()returns a PDS redirect.- PDS redirects to
GET /api/oauth/callback→ fetches profile via public Bluesky API → stores{did, handle, displayName, avatar}in an iron-session cookie (plrd_session, 30d). GET /api/statushydratesAuthProvideron mount.POST /api/logoutclears the cookie.
Admin gating is enforced in two places — both must pass:
- Client:
useAuth().isAdminchecksADMIN_DIDS(fromNEXT_PUBLIC_ADMIN_DIDS, comma-separated, else the built-in list insrc/lib/lexicons.ts). - Server:
PUT /api/pages/[rkey]re-checksADMIN_DIDS.includes(session.did).
Page records always live in plresearch.org's repo, but an authorized editor might be a different DID. PUT /api/pages/[rkey] calls getPlresearchAgent() which logs in as plresearch.org via app password (ATPROTO_HANDLE + ATPROTO_PASSWORD) and performs the putRecord. Missing either env var → page edits throw 500.
| Collection | Purpose |
|---|---|
org.plresearch.page |
Section-based page content. Fields: pageId, iconType, leads[], advisors[], sections[], updatedAt. Known rkeys: landing, about, areas, collaborate, area-ai-robotics, area-digital-human-rights, area-neurotech, area-economies-governance, area-eg-subareas, area-eg-impact, insights, publications, talks, tutorials, blog, authors. |
org.plresearch.opportunitySpace |
Opportunity-space records for generic areas. |
org.plresearch.post |
Legacy author posts. |
site.standard.document / site.standard.publication |
standard.site long-form content; body embedded under org.plresearch.markdownContent. |
src/lib/tid.ts uses @atproto/common-web's TID.nextStr() for sortable timestamp-based record keys.
Talks to INDEXER_URL / NEXT_PUBLIC_INDEXER_URL, default https://plresearch-indexer-production.up.railway.app/graphql. Exports fetchPage, fetchAllPages, fetchOpportunitySpaces, fetchOpportunitySpace, fetchAtproPosts, and helpers getSection / getSectionsWithPrefix.
- Queries use
uri(AT-URI), notrkey— parserkeyout of the URI when needed. - Fetches tag
indexer, revalidate 60s. PUT /api/pages/[rkey]callsrevalidateTag("indexer")to invalidate reads after writes.- Lexicon changes require redeploying the indexer — writes succeed but new fields won't appear in reads until then.
Editable pages have copy spread across four layers. Confusing them is the single most common mistake — see PR #4 (Sync focus area descriptions) for the canonical worked example, where four of the four layers were updated except the live one and the change was invisible on every page.
| # | Layer | Purpose | Visible on live site? |
|---|---|---|---|
| 1 | src/lib/focus-area-descriptions.ts (TS constant) |
Hardcoded canonical strings. | Only as fallback when the indexer record is empty. |
| 2 | content/areas/<slug>/_index.md frontmatter + src/data/generated/*.json + public/search-index.json |
Markdown source + build outputs from build-content.mjs. |
As fallback (area.summary) when both indexer + canonical are empty; also drives RSS + search. |
| 3 | data/atproto/pages/*.json seed files |
Static disk fixtures meant as the canonical source for layer 4. | Never directly. They are not auto-synced anywhere — purely a documentation + bootstrap source. |
| 4 | Live org.plresearch.page records on plresearch.org's PDS (read via Railway indexer) |
What every editable page actually renders. | Yes — this is the only one users see for non-empty records. |
The correct fallback order in render code is always indexer → canonical → frontmatter:
// ✓ correct — admin edits via /admin/.../edit/ take effect
const summary = heroSection?.subtitle
|| FOCUS_AREA_DESCRIPTIONS[slug]
|| area.summary
// ✗ WRONG — canonical short-circuits everything, indexer is dead code
const summary = FOCUS_AREA_DESCRIPTIONS[slug]
|| heroSection?.subtitle
|| area.summaryTo actually change what a page displays, pick one of three mechanisms:
-
Through the admin UI (recommended for one-off edits). Sign in at
/admin, navigate to the page's edit route (e.g./areas/digital-human-rights/edit/), update the field, save. The handlerPUT /api/pages/[rkey]writes to the PDS, callsrevalidateTag("indexer"), and the change is live within ~60s. Requires admin auth +ADMIN_DIDSmembership + server-sideATPROTO_HANDLE/ATPROTO_PASSWORD. -
Edit a seed JSON + run the sync script (recommended for bulk / scriptable changes). This is the path PR #4 should have taken:
- Edit the relevant
data/atproto/pages/<rkey>.json. - If you want the same string to appear as the empty-indexer fallback too, also update
src/lib/focus-area-descriptions.tsand the matchingcontent/areas/<slug>/_index.md(thennode scripts/build-content.mjsto regen the build outputs). - Add the
(rkey, sectionId, field)tuples you changed to theMANIFESTarray at the top ofscripts/sync-pages.mjs. node scripts/sync-pages.mjs --dry-runto preview the diff against the live PDS.node scripts/sync-pages.mjsto apply.
The script is field-scoped, not whole-record-replace: it reads the live record, patches only the fields you named, leaves everything else (advisors, body copy, icons) untouched. So out-of-band live edits are never clobbered. It bumps
updatedAtand is idempotent (skipwhen already in sync). - Edit the relevant
-
Direct PDS write via
@atproto/api. Last resort, e.g. for new lexicons. Mirror thegetPlresearchAgent()pattern: log in viaATPROTO_HANDLE/ATPROTO_PASSWORD, callagent.com.atproto.repo.putRecord({ repo: did, collection: "org.plresearch.page", rkey, record }). Don't forget to fetch the existing record first if you only want to patch a field — a bareputRecordreplaces the whole record.
Never assume editing layer 1, 2, or 3 alone changes the live site. They are dormant until layer 4 catches up. The sync-pages.mjs manifest is the explicit bridge from layer 3 → layer 4 and the only documented mechanism for batch updates.
| Method + Path | Purpose | Auth |
|---|---|---|
POST /api/login |
OAuth authorize | none |
GET /api/oauth/callback |
OAuth callback → session | OAuth code |
POST /api/logout |
Clear session | none |
GET /api/status |
Return session (or {}) |
none |
GET /api/oauth/client-metadata.json |
OAuth client metadata | none |
GET /api/oauth/jwks.json |
JWKS (empty {keys:[]} in public mode) |
none |
GET /api/users/[handle] |
Resolve handle → profile | none |
GET /api/pages |
List all org.plresearch.page records |
none |
GET /api/pages/[rkey] |
Fetch one page | none |
PUT /api/pages/[rkey] |
Update a page record | session + ADMIN_DIDS |
POST /api/posts |
Create site.standard.document on user's PDS |
OAuth session |
GET /.well-known/site.standard.publication |
Publication AT-URI | requires NEXT_PUBLIC_PUBLICATION_URI |
FA2 is special-cased: hardcoded routes under /areas/economies-governance/ with hand-curated JSON in src/data/fa2/. areas/[slug]/page.tsx explicitly filters it out via HARDCODED_AREA_SLUGS = ['economies-governance'].
| Route | Component | Data source |
|---|---|---|
/areas/economies-governance/ |
page.tsx |
indexer page area-economies-governance (fallback: hardcoded copy) |
.../subareas/ |
subareas/page.tsx |
indexer page area-eg-subareas + hardcoded 9-subarea list |
.../opportunity-spaces/ |
opportunity-spaces/page.tsx |
src/data/fa2/opportunityspaces.json |
.../opportunity-spaces/[slug]/ |
SSG via generateStaticParams |
opportunityspaces.json |
.../impact/ |
impact/page.tsx |
indexer page area-eg-impact |
.../impact/report-2025/ |
report-2025/page.tsx |
src/data/fa2/impact.json + projects.json |
.../impact/live-dashboard/ |
live-dashboard/page.tsx |
placeholder |
.../projects/ |
projects/page.tsx → ProjectsExplorer.tsx (client) |
src/data/fa2/projects.json |
.../dependency-graph/ |
dependency-graph/page.tsx |
lib/content dep-graph entries |
.../dependency-graph/[slug]/ |
DependencyGraph.tsx (d3-force) |
dependency-graph/data/ |
.../edit/ |
edit/page.tsx |
inline editor over area-economies-governance |
The other areas (ai-robotics, digital-human-rights, neurotech) each have their own opportunity-spaces/ + [slug] pair powered by src/data/fa2/{ai,dhr,neuro}-opportunityspaces.json (yes, named under fa2/ for historical reasons).
Edits to the JSON files require a redeploy; edits via /admin go through ATProto → indexer and invalidate via revalidateTag("indexer").
| Var | Required | Default | Consumer / what breaks if missing |
|---|---|---|---|
COOKIE_SECRET |
prod | dev fallback ('development-secret-at-least-32-chars!!') |
src/lib/session.ts; must be ≥32 chars (envalid enforced) |
PUBLIC_URL |
prod | empty → http://127.0.0.1:$PORT |
OAuth client ID; empty disables confidential-client mode |
ATPROTO_JWK_PRIVATE |
prod | empty | Confidential OAuth client. Missing → public-client fallback. Generate with node scripts/generate-jwk.js. |
ATPROTO_HANDLE |
for page edits | empty | PUT /api/pages/[rkey] throws — no page writes without it |
ATPROTO_PASSWORD |
for page edits | empty | Same as above (Bluesky app password for plresearch.org) |
NEXT_PUBLIC_ADMIN_DID |
no | did:plc:pgwr6hkosgznfl5nz7egajei |
The repo page records are written to. |
NEXT_PUBLIC_ADMIN_DIDS |
no | built-in 3-DID list in lexicons.ts |
Admin allowlist — missing DIDs can't see or use admin UI, and are blocked server-side. |
NEXT_PUBLIC_PUBLICATION_URI |
no | unset | /.well-known/site.standard.publication 404s; /api/posts falls back to https://www.plresearch.org |
INDEXER_URL / NEXT_PUBLIC_INDEXER_URL |
no | https://plresearch-indexer-production.up.railway.app/graphql |
fetchPage et al. return null; pages fall back to hardcoded copy |
PORT |
no | 3000 | Affects localhost OAuth client_id |
Only COOKIE_SECRET, PUBLIC_URL, ATPROTO_JWK_PRIVATE, ATPROTO_HANDLE, ATPROTO_PASSWORD flow through envalid in src/lib/env.ts. Everything else is read straight from process.env.
⚠
.env.exampleis stale — it omitsATPROTO_HANDLE,ATPROTO_PASSWORD,NEXT_PUBLIC_ADMIN_DIDS,NEXT_PUBLIC_PUBLICATION_URI, and itsINDEXER_URLhint points at the oldapi.hi.gainforest.appURL. If you touch env handling, refresh.env.examplein the same PR.
Build / types
- Forgot to run
build-content.mjs?tscimports from generated JSON; it will compile stale types. Bothdevandbuildauto-run the script; runningtscstandalone does not. .next/caches types aggressively. After deleting pages, changinggenerateStaticParams, or switching branches,rm -rf .next.params: Promise<{ slug: string }>in Next 15. Forgettingawaitproduces a silent type error only caught bytsc --noEmit.
Content
unaffiliated: trueon a publication silently hides it everywhere — there's no "draft" flag; unpublished content shouldn't live incontent/.- Avatars come from
public/images/authors/<slug>/avatar.*, not frontmatter. - Author references mix internal slugs and free-text external names. Always fall back to rendering the string.
- Adding a frontmatter field requires edits in both the mapper (
scripts/build-content.mjs) and theTypeinsrc/lib/content.ts. devdoes not watchcontent/— restart after markdown edits.- Hugo shortcodes (other than
{{< youtube ID >}}in talks) are not expanded.
Routing
- Missing trailing slashes 404 silently (
skipTrailingSlashRedirect: true). Grep diffs forhref="/foo"without the trailing/. - Don't add pages under
src/app/research/—/research/:path*redirects to/insights. - Adding a focus area? Not via
areas/[slug]/if it'seconomies-governance— that's the hardcoded tree.
UI / styling
- This is Tailwind v4. No
tailwind.config.js. Tokens live in@themeinsideglobals.css. - Custom
text-xl/text-lgoverride defaults; usetext-sm/text-basefor body copy. text-blue/bg-blueresolve to the brand--color-blue, not Tailwind's default blue.- Components using hooks,
usePathname, oruseAuthneed'use client'at the top. - Raw
<img>throughout;next/imageoptimization is off project-wide. - Icon typos fail silently — confirm the file exists in
public/icons/. - Adding a fullscreen route? Append a regex to
FULLSCREEN_PATTERNSinSiteShell.tsx.
Auth / ATProto
- Dev without
ATPROTO_JWK_PRIVATEdrops to public-client mode;client-metadata.json+jwks.jsonchange shape. - Callback URL must be
127.0.0.1, notlocalhost. - Page writes need both the logged-in admin's OAuth session and server-side
ATPROTO_HANDLE/ATPROTO_PASSWORDto impersonate plresearch.org. - Lexicon field changes only show up in reads after the Railway indexer is redeployed.
/admin,/edit,/write, all inline editors are client-only — don't convert them to SSG.- OAuth session lives in both an in-memory Map and the cookie (serverless cold-start safety). Don't remove the cookie sync when refactoring
auth/client.ts. - Editing
data/atproto/pages/*.jsondoes NOT update the live site. Those are seed fixtures, not a sync source. Runnode scripts/sync-pages.mjsafter editing — read §6 "Updating page text — the four-layer model". This is how PR #4 silently shipped invisible changes. - Hardcoded canonical strings must come second in fallback chains (
indexer || canonical || frontmatter). Putting them first short-circuits the indexer and breaks every/adminedit on that field. PR #4 broke/areas/[slug]/page.tsxexactly this way; fixed infd4730e.
The repo lives at github.com/protocol/plrd.org (moved from daviddao/plrd.org; daviddao/plrd-v2 was an earlier name). main auto-deploys to Vercel. vercel.json sets only framework: nextjs + buildCommand: npm run build. *.pdf is gitignored.
Decentralized publishing via ATProto is already partially in place for CMS-editable pages. The longer-term plan is to move blog posts (and eventually more content) onto ATProto records rather than the markdown/git workflow. New editable content should prefer the org.plresearch.page / site.standard.document lexicons over adding new markdown collections.