Skip to content

Latest commit

 

History

History
434 lines (323 loc) · 29 KB

File metadata and controls

434 lines (323 loc) · 29 KB

AGENTS.md

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.


0. TL;DR for agents

  • Canonical repo: github.com/protocol/plrd.org. The project moved here from daviddao/plrd.org (earlier plresearch.org). All work — branches, PRs, pushes — targets protocol/plrd.org.

  • Stack: Next.js 15 App Router + React 19 + Tailwind v4 CSS-first + TypeScript. No ESLint, no test runner. Verify with npx tsc --noEmit and npm run build.

  • Content pipeline: Markdown in content/scripts/build-content.mjs (prebuild) → JSON in src/data/generated/ → typed arrays in src/lib/content.ts → pages. JSON is checked into git.

  • Trailing slashes are mandatory on every internal href. skipTrailingSlashRedirect: true means missing slashes 404 silently.

  • Path alias: @/*./src/*. Never use relative imports across src/.

  • 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 generic areas/[slug]/ template.


1. Commands

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.


2. Repository layout (where things actually live)

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

3. Content pipeline

Build script (scripts/build-content.mjs)

  • Scans content/<section>/. Each subdirectory with index.md or _index.md is a record; loose *.md files 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 the Type in src/lib/content.ts.
  • Sorting: publications / talks / blog by date desc; authors by name asc (localeCompare); outreach by numeric weight asc; tutorials + areas use readdir order.
  • unaffiliated: true on a publication drops it from the site, RSS, and search index.
  • Avatars: buildAuthors probes public/images/authors/<slug>/ for any file starting with avatar. Frontmatter resources: is decorative and ignored.
  • Dependency-graph data: content/areas/economies-governance/dependency-graph/*.md parses bottlenecks/gates/strands/interventions/feedbackLoops arrays; per-node tooltip fields are hoisted into a sibling tooltips map 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 buildSearchIndex explicitly.

Generated JSON (src/data/generated/)

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.json is generated but not consumed — outreach pages render from markdown elsewhere. Don't rely on it without wiring it up.

Typed API (src/lib/content.ts)

Pages must import { publications, authors, ... } from '@/lib/content'. Never read src/data/generated/*.json directly; never parse markdown at runtime.

Markdown quirks

  • remark-html runs with sanitize: falseraw HTML in markdown passes through and reaches the DOM via dangerouslySetInnerHTML.
  • Hugo shortcodes are not expanded. {{< youtube ID >}} survives into HTML; src/app/talks/[slug]/page.tsx regex-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.

4. App Router & routing

Layout & conventions

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-edit src/lib/content.ts, it's regenerated.
  • Use notFound() inside the default export; return { title: 'Not Found' } from generateMetadata (don't throw).
  • Some pages also fetch editable CMS content via fetchPage(rkey) from @/lib/indexer and fall back to hardcoded defaults when the indexer is unreachable. See src/app/page.tsx, areas/[slug]/page.tsx.

Routing rules

  • trailingSlash: true + skipTrailingSlashRedirect: true in next.config.ts → every internal href must end with /. No auto-redirect.
  • Path alias @/*./src/*. Grep diffs for relative imports across src/.
  • Redirects live in next.config.ts:
    • /areas/upgrade-economies-governance/*/areas/economies-governance/*
    • /research/:path*/insights/:path* (don't add new pages under src/app/research/)
  • images.unoptimized: true: do not rely on next/image resizing; the codebase mostly uses raw <img>.

Special / dynamic routes

  • src/app/api/*/route.ts — all marked export const dynamic = "force-dynamic".
  • src/app/.well-known/site.standard.publication/route.ts — AT-URI discovery (requires NEXT_PUBLIC_PUBLICATION_URI).
  • /admin, /edit, /write, /about/edit, /areas/[slug]/edit, /areas/economies-governance/edit — client components requiring ATProto auth. Not statically exportable.

Adding a new page — checklist

  1. Create src/app/<route>/page.tsx (server component unless you need hooks/auth).
  2. If markdown-backed: add source under content/<section>/ and extend both scripts/build-content.mjs (mapper) and src/lib/content.ts (type + re-export).
  3. For [slug] pages: implement generateStaticParams and generateMetadata.
  4. Add a <Breadcrumb items={...}/> above the page body.
  5. Wire nav via src/lib/site-config.ts (mainNav / footerNav) — pages are not auto-indexed.
  6. If it should be searchable, append it to buildSearchIndex in scripts/build-content.mjs.
  7. If CMS-editable, add an rkey to EDIT_ROUTES in src/components/EditPageButton.tsx, render <EditPageButton rkey=".../>, and add a matching /edit route using useRequireAdmin + usePageEdit(rkey).
  8. Link it as /your-route/ (trailing slash).

5. UI layer

Site shell

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 inventory (src/components/)

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'.

Styling (Tailwind v4)

  • No tailwind.config.js. Theme tokens live in an @theme { ... } block inside src/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/90 resolve to the brand color, not Tailwind's blue-500.
  • Custom font sizes override Tailwind defaults (text-xl = 48px, text-lg = 32px). Be deliberate with size classes.
  • Breakpoints: sm/md/lg default, xl = 1146px.
  • Common patterns: max-w-6xl mx-auto px-6 page container, rounded-full pill buttons, bg-white/95 backdrop-blur-sm sticky surfaces, @utility scrollbar-hide.
  • Prose rules for rendered Markdown live under .page-content and .page-top in globals.css.

Icons, images, avatars

  • Icons: public/icons/<name>.svg, referenced as <img src="/icons/<name>.svg">. Typos fail silently (broken image). Social icons include github, twitter, linkedin, orcid, google-scholar, arxiv, doi, acm, elsevier, researchgate, etc.; chrome includes chevron-*, menu, search.
  • Author avatars: authors.find(...).avatarPath is string | null; AuthorCard falls back to an initial badge.
  • next/image is not used here. Image optimization is disabled project-wide.

Utilities (src/lib/format.ts)

formatDate(str)"Jan 5, 2026" via Intl.toLocaleDateString. stripFaPrefix(title) removes FA\d+:\s*. slugToName(slug) title-cases a dash-separated slug.


6. ATProto, auth, and the editable CMS layer

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.

Client modes

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.

Auth flow

  1. POST /api/login with handle → client.authorize() returns a PDS redirect.
  2. 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).
  3. GET /api/status hydrates AuthProvider on mount.
  4. POST /api/logout clears the cookie.

Admin gating is enforced in two places — both must pass:

  • Client: useAuth().isAdmin checks ADMIN_DIDS (from NEXT_PUBLIC_ADMIN_DIDS, comma-separated, else the built-in list in src/lib/lexicons.ts).
  • Server: PUT /api/pages/[rkey] re-checks ADMIN_DIDS.includes(session.did).

The write-as-plresearch trick

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.

Lexicons (src/lib/lexicons.ts)

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.

Indexer (src/lib/indexer.ts)

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), not rkey — parse rkey out of the URI when needed.
  • Fetches tag indexer, revalidate 60s.
  • PUT /api/pages/[rkey] calls revalidateTag("indexer") to invalidate reads after writes.
  • Lexicon changes require redeploying the indexer — writes succeed but new fields won't appear in reads until then.

Updating page text — the four-layer model (read this before changing any page copy)

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.summary

To actually change what a page displays, pick one of three mechanisms:

  1. 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 handler PUT /api/pages/[rkey] writes to the PDS, calls revalidateTag("indexer"), and the change is live within ~60s. Requires admin auth + ADMIN_DIDS membership + server-side ATPROTO_HANDLE/ATPROTO_PASSWORD.

  2. Edit a seed JSON + run the sync script (recommended for bulk / scriptable changes). This is the path PR #4 should have taken:

    1. Edit the relevant data/atproto/pages/<rkey>.json.
    2. If you want the same string to appear as the empty-indexer fallback too, also update src/lib/focus-area-descriptions.ts and the matching content/areas/<slug>/_index.md (then node scripts/build-content.mjs to regen the build outputs).
    3. Add the (rkey, sectionId, field) tuples you changed to the MANIFEST array at the top of scripts/sync-pages.mjs.
    4. node scripts/sync-pages.mjs --dry-run to preview the diff against the live PDS.
    5. node scripts/sync-pages.mjs to 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 updatedAt and is idempotent (skip when already in sync).

  3. Direct PDS write via @atproto/api. Last resort, e.g. for new lexicons. Mirror the getPlresearchAgent() pattern: log in via ATPROTO_HANDLE/ATPROTO_PASSWORD, call agent.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 bare putRecord replaces 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.

API routes

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

7. FA2 (Upgrade Economies & Governance)

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.tsxProjectsExplorer.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").


8. Environment variables

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.example is stale — it omits ATPROTO_HANDLE, ATPROTO_PASSWORD, NEXT_PUBLIC_ADMIN_DIDS, NEXT_PUBLIC_PUBLICATION_URI, and its INDEXER_URL hint points at the old api.hi.gainforest.app URL. If you touch env handling, refresh .env.example in the same PR.


9. Gotchas (the "why isn't this working" list)

Build / types

  • Forgot to run build-content.mjs? tsc imports from generated JSON; it will compile stale types. Both dev and build auto-run the script; running tsc standalone does not.
  • .next/ caches types aggressively. After deleting pages, changing generateStaticParams, or switching branches, rm -rf .next.
  • params: Promise<{ slug: string }> in Next 15. Forgetting await produces a silent type error only caught by tsc --noEmit.

Content

  • unaffiliated: true on a publication silently hides it everywhere — there's no "draft" flag; unpublished content shouldn't live in content/.
  • 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 the Type in src/lib/content.ts.
  • dev does not watch content/ — 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 for href="/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's economies-governance — that's the hardcoded tree.

UI / styling

  • This is Tailwind v4. No tailwind.config.js. Tokens live in @theme inside globals.css.
  • Custom text-xl/text-lg override defaults; use text-sm/text-base for body copy.
  • text-blue / bg-blue resolve to the brand --color-blue, not Tailwind's default blue.
  • Components using hooks, usePathname, or useAuth need 'use client' at the top.
  • Raw <img> throughout; next/image optimization is off project-wide.
  • Icon typos fail silently — confirm the file exists in public/icons/.
  • Adding a fullscreen route? Append a regex to FULLSCREEN_PATTERNS in SiteShell.tsx.

Auth / ATProto

  • Dev without ATPROTO_JWK_PRIVATE drops to public-client mode; client-metadata.json + jwks.json change shape.
  • Callback URL must be 127.0.0.1, not localhost.
  • Page writes need both the logged-in admin's OAuth session and server-side ATPROTO_HANDLE/ATPROTO_PASSWORD to 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/*.json does NOT update the live site. Those are seed fixtures, not a sync source. Run node scripts/sync-pages.mjs after 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 /admin edit on that field. PR #4 broke /areas/[slug]/page.tsx exactly this way; fixed in fd4730e.

10. Deployment

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.


11. Future direction

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.