pantheon-site-build
janusUse 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
@themeinapp/globals.css— notailwind.config.ts. Tokens becomevar(--color-*)/var(--font-*). next/font/googlewith CSS variables wired into@theme. Graceful fallback when font CDN blocked at build.lib/catalog.tsnormalizes janus's.claude-plugin/plugin.jsonand argus's.codex-plugin/plugin.jsoninto onePlugintype with memoizedgetCatalog().- PII tripwire in
lib/sanitize.tsruns against every manifest + skill frontmatter string.scripts/validate_catalog.tsis wired asprebuildso PII fails the build fast. - Static skill detail pages via
generateStaticParamsovercatalog.skills.next-mdx-remote/rscinside.prose-apotheosisso authors don't think about styling. - Rail state in
localStorageunderpantheon.rail; a CSS var--rail-wbridges client state to layoutpadding-leftwithout prop drilling.
IIIWhat the planner got wrong
tailwind.config.tslisted in file tree. Tailwind 4 doesn't need it; inline@themeis canonical.@cloudflare/next-on-pagesdoes not work on Windows (package banner says so). Treat the Pages adapter step as Linux/CI only.- Validator wants to be a npm
prebuildscript, 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 viaJANUS_PATH/ARGUS_PATH. - Manifest normalization: prefer
interface.displayName+interface.shortDescriptionwhen present (argus); fall back to rootname/description(janus). A skill'sbodyis the post-frontmatterSKILL.mdcontent — rendered MDX on the apotheosis. - PII list locked in
lib/sanitize.ts. Manifest + frontmatter strings checked; body content not. Build fails withPiiViolationnaming 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) orurllib.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.tsxandlib/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:localStorageunderpantheon.rightrail,]keyboard shortcut, CSS variable--rrail-wbridging client state to the layout'spadding-right. Visible atlgand 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-Filenameheader carries original name. Body is the raw file.Content-Lengthand post-readbyteLengthboth checked againstMAX_BYTES(2 MiB).GET /index— bearer-gated. Returns{ schemaVersion: 1, indexVersion, updatedAt, items }.indexVersionincrements monotonically; consumers compare to invalidate caches.GET /item/:id— bearer-gated. Raw markdown or html with the correctContent-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.pngandargus.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-routegenerateMetadata.
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.