XI (11)

pantheon-site-build

janus

Use when building a Next.js + Cloudflare Pages catalog site that reads sibling plugin manifests at build time. Covers content sources, PII tripwire, design system tokens, and the apotheosis page pattern.

Pantheon Site Build

Meta-skill captured at P1 close. Records what the planner got right, what it got wrong, and the patterns that survived contact with the build.

IPurpose

A static catalog site that reads two sibling plugins (janus, argus) from disk at build time and renders a dignified showcase. No runtime fetching, no Workers, no images in P1 — the typographic plate stands alone.

IIWhat worked

  • Tailwind 4 inline @theme in app/globals.css — no tailwind.config.ts. Tokens become var(--color-*) / var(--font-*).
  • next/font/google with CSS variables wired into @theme. Graceful fallback when font CDN blocked at build.
  • lib/catalog.ts normalizes janus's .claude-plugin/plugin.json and argus's .codex-plugin/plugin.json into one Plugin type with memoized getCatalog().
  • PII tripwire in lib/sanitize.ts runs against every manifest + skill frontmatter string. scripts/validate_catalog.ts is wired as prebuild so PII fails the build fast.
  • Static skill detail pages via generateStaticParams over catalog.skills. next-mdx-remote/rsc inside .prose-apotheosis so authors don't think about styling.
  • Rail state in localStorage under pantheon.rail; a CSS var --rail-w bridges client state to layout padding-left without prop drilling.

IIIWhat the planner got wrong

  • tailwind.config.ts listed in file tree. Tailwind 4 doesn't need it; inline @theme is canonical.
  • @cloudflare/next-on-pages does not work on Windows (package banner says so). Treat the Pages adapter step as Linux/CI only.
  • Validator wants to be a npm prebuild script, not a one-shot.
  • pairedNumeral(n) is more useful than spec'd — used by stoa, rail, apotheosis.

IVContent source + privacy

  • Default paths: C:\Janus, C:\Argus. Override via JANUS_PATH / ARGUS_PATH.
  • Manifest normalization: prefer interface.displayName + interface.shortDescription when present (argus); fall back to root name/description (janus). A skill's body is the post-frontmatter SKILL.md content — rendered MDX on the apotheosis.
  • PII list locked in lib/sanitize.ts. Manifest + frontmatter strings checked; body content not. Build fails with PiiViolation naming source + offending token + snippet.

VApotheosis page

Dark plate (page-vignette), 720px centered column. Header: numeral, name (Cormorant clamp(40,6vw,72)), plugin pill, gold rule, description. .prose-apotheosis wraps the MDX; prose styles in globals.css. No carousel, no share. Verification: npm run validate | typecheck | lint | build. Pages adapter is Linux/CI only.

VIbrain wiring (P2)

schema (locked at schemaVersion: 1)

Brain = { schemaVersion: 1; generatedAt: string; operator: { displayName; handle; org };
          projects: [{slug,name,status}]; taxonomy: { tiers: [{id:1|2|3,name,description}],
          modes: [{id,name,description}] } }

Mirror the type in two places: workers/brain/src/schema.ts (Worker source of truth) and lib/brain.ts (site consumer). Duplicate, don't cross-import — workers/ is excluded from the Next tsconfig so the project doesn't try to compile Worker code.

auth pattern

Bearer token compared with a constant-time loop. Missing or wrong token returns new Response(null, { status: 401 }) — no body, no header leak. Token sourced from BRAIN_TOKEN Wrangler secret in prod; .dev.vars (gitignored) in local dev.

fail-soft fetch idiom

Both clients (Python brain_client.py, TS lib/brain.ts) follow the same shape:

  • hard timeout (2 s) via AbortController (TS) or urllib.request.urlopen(timeout=2.0) (Python)
  • catch every exception; never raise into the caller
  • return a discriminated "live | fallback" type (TS) or Optional[dict] (Python)
  • no print on failure (Python); no console.error on failure (TS) — silence is the contract

The janus gate hook wraps the brain call in its own try/except so a Worker outage cannot break Claude Code startup. If the client returns None, the additionalContext is emitted without the brain line — never with a stale or error line.

what surprised me

@cloudflare/workers-types was unnecessary to make satisfies ExportedHandler<Env> typecheck — Wrangler ships the types implicitly. Kept it in devDependencies for IDE completion, but typecheck passes without the explicit import. Confirms the Worker source has zero ambient assumptions about the wider repo.

The site's home page already needed to be async for ISR; adding export const revalidate = 300 plus an await getBrainSync() slotted in with zero structural changes elsewhere. The vault section is the first home section with no inscription — got a ζ (zeta) section marker instead. Section markers are typographic, not part of the locked-eight inscriptions, so this is consistent with the brand contract.

VIIoracle wiring (P3)

scheduled cron Worker

Single cron 0 13 * * *. The handler runs once and bails on the first error — no in-day retries. Feed failures are logged to KV oracle:errors (capped to last 50). The locked feed list lives in feeds.ts as readonly Feed[] with priority: number; the cost guardrail repeatedly drops the lowest-priority feed until the rendered user prompt fits within MAX_INPUT_TOKENS. Token estimation is a 1-token-per-4-chars heuristic — conservative on purpose; better to drop a feed than overshoot.

write order is the visibility contract

R2 write first, KV pointer second. A successful run flips oracle:latest only after the day's MDX is durable in R2. If the KV step fails, the R2 object is left in place and the run bails — the next day's run overwrites cleanly. Readers (the site, anything else) must read KV first and dereference into R2; an unwritten KV key means "the oracle has not yet spoken." No partial state.

MDX + JSON shapes

The Worker writes both: MDX file in R2 (oracle/YYYY-MM-DD.md) for archive durability, and a parsed JSON envelope returned by GET /latest for site consumption. The MDX is canonical; /latest reads R2 and re-parses it on each request. A trivial frontmatter + heading-with-link parser is sufficient — we control both writer and reader.

dev / CI ergonomics

.dev.vars accepts ORACLE_MOCK=1 to bypass the Claude call and write a synthetic digest sourced from the real feeds. Never set in production. This makes wrangler dev --test-scheduled produce a real R2/KV write end-to-end without burning a Claude API key, which is the only practical way to satisfy the scheduled-write success criterion in local dev.

read endpoint is public

The site fetches GET /latest unauthenticated. The digest is non-sensitive — it's destined for a public webpage. Write paths stay gated: cron is internal, POST /trigger requires the ORACLE_TOKEN bearer.

site changes

  • Deleted OraclePlaceholder.tsx. The oracle section now renders the digest inline.
  • New Oracle.tsx and lib/oracle.ts. The fetcher accepts an empty / unreachable Worker and returns { status: "empty", reason: "the oracle has not yet spoken." } — that string is the locked fallback copy.
  • New RightRail.tsx. Mirrors the left rail's collapse pattern: localStorage under pantheon.rightrail, ] keyboard shortcut, CSS variable --rrail-w bridging client state to the layout's padding-right. Visible at lg and above only (the home page already runs out of horizontal real estate below 1024).

what surprised me

The Algolia query lives entirely in the URL; no separate body schema. Treating it as a kind: "algolia" feed and parsing the JSON hits array keeps the contract uniform with RSS/Atom. The MCP "spec releases" feed is GitHub Atom, not RSS — handled by a separate parser (entries instead of items, link href attribute instead of nested <link>).

VIIIvault wiring (P4)

auth: single bearer token, server-side only

VAULT_TOKEN is one Wrangler secret. app/api/upload/route.ts reads it server-side and forwards as a bearer header; the browser never sees it. Plugin clients read the same token from local env. Worker: constant-time compare, 401 with no body on miss.

routes

  • POST /upload — bearer-gated. Content-Type ∈ {text/markdown, text/html}. X-Filename header carries original name. Body is the raw file. Content-Length and post-read byteLength both checked against MAX_BYTES (2 MiB).
  • GET /index — bearer-gated. Returns { schemaVersion: 1, indexVersion, updatedAt, items }. indexVersion increments monotonically; consumers compare to invalidate caches.
  • GET /item/:id — bearer-gated. Raw markdown or html with the correct Content-Type.

rate limit + sanitization

KV bucket per floor(now / window). Token is sha-256'd before forming the KV key so the raw secret never enters KV. Defaults: 20 writes / 60s / token, 429 + Retry-After on overflow. Filenames: strip path separators, collapse non-alnum to -, cap 120. Slugs: lowercase alnum, cap 64; on collision append Date.now().toString(36) — never silently overwrite.

site UI

P2's Vault.tsx (brain sync) renamed → BrainSync.tsx. New Vault.tsx wraps the section: brain card left, index panel right, dropzone (VaultUpload.tsx) below. Dropzone is client-side: drag/drop, click-to-browse, idle | uploading | ok | error status line. Uploads → /api/upload (Node runtime, force-dynamic).

plugin behavior: additive, not autoload

Gate hook appends vault: N items, last update T to UserPromptSubmit additionalContext — same shape as the brain line, same fail-soft. Bodies never auto-loaded; operator pulls items on demand via the runtime client.

vault_client.py TTL cache

%TEMP%\janus_vault_cache.json, keyed by URL. TTL = 300s. Fresh → no network. Stale + reachable → refresh. Stale + unreachable → return cache (graceful staleness, per-URL match). No cache + unreachable → None. Never raises.

what surprised me

runtime = "edge" on the upload route broke multipart parsing under next start on Windows (HTTP 000 from curl). Removed; runs in Node under next start, converts cleanly under @cloudflare/next-on-pages.

IXpolish + assets (P5)

image generation prompts (locked)

Operator runs externally; site detects each file via fs.existsSync at build time — present → use; absent → fall back. No layout shift either way.

Style stem (prepend to every prompt): white Carrara marble bas-relief, deep onyx background (#0a0c10), single warm-gold rim light from below at low angle, museum gallery at night, cinematic, 8K render, no text, no signature, no watermark.

  • public/monuments/hero.png (1440×900) — Janus left third (two faces in profile, one young one old, both serene), Argus right third (cloaked figure woven with a hundred open eyes catching the gold light), a low stone altar with a single small flame between. Symmetrical, reverent.
  • public/monuments/janus.png and argus.png (1024×1024) — single subject centered on a fluted column, classical proportions, half-length.
  • public/glyphs/{slug}.png (256×256)[stem]. A single carved-stone glyph for the skill "{NAME}": {one-line metaphor}. Centered, ~60% canvas fill, no background flourishes. Operator writes the per-skill metaphor (e.g. janus-router = "two paths diverging from a keystone"; argus-validation-gate = "an open eye above a closed gate").

what P5 moved

  • HeroMonument.tsx + build-time detector; falls back to typographic hero.
  • Glyph rendering in stoa cards via <SkillGlyph slug=…>; default carved-stone SVG fallback.
  • Apotheosis: drop cap on first paragraph; chapter <h2> prefixed with roman numeral via custom MDX components.
  • 404 inscription drifts 3px / 8s ease-in-out infinite.
  • app/icon.tsx, opengraph-image.tsx (ImageResponse), robots.ts, sitemap.ts, per-route generateMetadata.

Lighthouse + axe are operator-run

Headless verification needs a browser binary the build sandbox doesn't have. Operator runs lighthouse --preset=desktop and axe against /, /skills/janus/janus-router, /not-found. Floor: 98 on all four Lighthouse categories; 0 axe violations.