<!-- audience: AI coding agents first — optimize for activation, not prose aesthetics -->
# mod-cms — Agent Operating Manual

**Product:** mod-cms is a CMS blueprint — pages, posts, and site settings authored through a standard interface, served with server-rendered HTML. Built on Astro + Cloudflare Workers + D1 (SQLite; default, zero-external-DB). Neon/Postgres opt-in via `DATABASE_URL` secret (`env.DB` D1 binding takes priority when both present). Consumes `@platform-modules/content` for persistence and `@platform-modules/auth` for access control. Deploy it as a content website: editorial blog, documentation site, project site, or headless CMS backend.

**Scope of these rules: this app only.** Not the platform packages. Not any other consuming app. Visual decisions → defer to DESIGN.md.

---

## Pages in scope

- `index` — landing: hero body (editable via CMS), module inventory, blueprint links, latest posts
- `blueprint/[slug]` — dependency graph + composed modules list
- `module/[slug]` — capability spec table + dependency graph
- `post/[slug]` — editorial reading view
- `ui-kit` — live token reference and component showcase
- `404` — one sentence, one action
- `contact` — public contact form (no-JS POST to `/api/contact`)

Every page requires a matching empty state and error state for each dynamic section. Shipping a page without its empty/error states is incomplete.

---

## Design fundamentals

Four principles. Project-specific rules — not textbook definitions. Breaking any one fails the screen. Visual implementation details in DESIGN.md; the rules below govern intent.

**Contrast — editorial sharpness, not compliance minimum.**
Ink-on-bg ratio ≥ 12:1 (not WCAG 4.5:1). Two typeface families — Fraunces for display and section openings, Schibsted Grotesk for everything else — must read as a deliberate pairing, not a default stack. The single accent color appears at most once per visible section. Contrast comes from isolation: one colored element surrounded by neutral space hits harder than four competing colors.

**Repetition — one canonical form, applied everywhere.**
Exactly one Card primitive, one Field primitive, one Action primitive — same markup and same tokens across all six pages. Never invent a custom card for a single context. The 2px radius is universal: any surface using a different radius is a defect. The mono face appears exclusively in spec tables, metadata, code, and token labels — never decoratively.

**Alignment — left-anchored, never centered.**
Default content starts at column 2 of the 12-column grid. Headlines end before column 10 — never stretch full width. Every page must have at least one section where primary content is left-anchored with 3–4 columns of empty space to its right. If every section reads as centered, the alignment rule is broken.

**Proximity — group by relationship, separate by distance.**
Labels sit immediately above their inputs — never beside, never below, never as placeholder text. The eyebrow/kicker descriptor sits 8px below its headline as a subordinate identifier, not above it as an eyebrow label. Unrelated sections separated by ≥ 96px of vertical gap. Elements within the same group separated by ≤ 48px.

---

## Conventions

**Language and punctuation**
- Sentence case everywhere: headings, labels, buttons, navigation items. Title Case only for proper nouns and the product name.
- En-dashes (–) for ranges and parenthetical breaks, not hyphens. Curly apostrophes ('), not straight ('). Ellipsis character (…), not three periods.
- Dates in UI: "18 June 2026" — day month year, no ordinals, no ISO strings visible to users.
- No emoji anywhere in product UI: not in headings, labels, buttons, empty states, error messages, or navigation.

**Iconography**
- No Lucide, Heroicons, or any decorative icon set in headings, labels, navigation, or section openers.
- Icons permitted only inside Action buttons as functional affordances (e.g. an arrow indicating navigation). Never standalone decoration.

**Forms**
- Label always above the input. Never as placeholder text. Never beside the input.
- Error messages appear as inline prose immediately below the relevant field: "Email is required." — not a red banner above the form, not a toast notification.
- Exactly one primary action per screen. Never a ghost button paired beside a primary CTA.

**Copy**
- Error messages: declarative prose. "That email is already in use." Not "Oops!" Not "Uh oh, something went wrong!" Not emoji.
- Empty states: one sentence, one action. No illustration. Written like a person. Full rules in DESIGN.md §7.
- Button labels: verb + object where space allows ("Publish post", "Delete entry") — not bare verbs where context is ambiguous.
- Loading states: silent skeleton blocks. No "Loading…" text unless the skeleton is impossible to implement.

---

## Platform constraints

mod-cms consumes `@platform-modules/*` packages via workspace links (or installed from the package registry for deployed instances). Rules for agents working inside this app:

- NEVER edit a module's source from inside this app. All modules live in `packages/`. A behavior change in a module requires a change against that package, not an in-app patch.
- DESIGN.md is the visual authority. Code that contradicts DESIGN.md is a defect even if it renders and passes tests.
- Prefer OKLCH for app-authored CSS — no HSL, no rgb(). EXCEPTION — color values flowing through the `@platform-modules/ui-tokens` theme-engine `ColorTokens` contract are HEX by seam mandate (the engine's `parseHex`/`validateContrast` are hex-only; converting them to OKLCH breaks the contrast gate). The engine emits these tokens (`themeToCss`) — NEVER hand-convert engine-driven values. Applies to `src/lib/theme.ts` (`PaletteSet` palette) and the engine-emitted `--mod-color-*`. (The `--graph-*` blueprint palette in `theme.css` is hex baseline — pre-existing, not engine-driven.)
- CF Workers runtime: no Node.js built-ins. All server-side code uses web-standard Request/Response only.
- Spec-first: if a change contradicts a spec, amend the spec first, then change the code. Code diverging from its spec is a defect even when green.
- pnpm workspace: sibling packages linked as workspace:^. Never edit node_modules. Run pnpm install at the monorepo root after any package.json change.

---

## Before you ship

Check in order. Stop at the first failure and fix before continuing.

1. Anti-patterns scan: run the screen against DESIGN.md §9 anti-patterns list. Any match is an automatic fail.
2. Two most common failures — check explicitly:
   - Layout too centered: is at least one section left-anchored with empty columns right of column 9?
   - Typography slop: is any banned face (Inter, Geist, Roboto, system-ui, etc.) present in font-family? Is Fraunces used outside of display/section-opening roles?
3. Contrast: ink-on-bg ≥ 12:1. Verify with an OKLCH-aware tool (Huetone or equivalent) — do not eyeball.
4. Forms: every input has a visible label above it (not a placeholder).
5. Actions: exactly one primary action per screen.
6. Empty + error states: every dynamic section has both states written and rendered.
7. Copy: no emoji, no "Oops!", no decorative icons in headings or labels.
8. Wordmark test (DESIGN.md §10): cover the logo. Does the screen still read as a specific, opinionated product from typography, rhythm, and alignment alone?

All eight pass → ship. Any one fails → fix first, then re-check from step 1.

---

## Learned Rules

### mod-cms-astro-env-is-undefined | fired:1 | 2026-06-24
`const env = Astro.env;` (or `requireAdmin(req, Astro.env, …)`) in an `.astro` page → wrong. `Astro.env` does NOT exist under @astrojs/cloudflare — it resolves to `undefined`, AND a render-scope `const env = Astro.env;` legally SHADOWS the module-scope `import { env } from 'cloudflare:workers'` (import = module scope, const = render-fn scope, no redeclare error), so every downstream `getFullDb(env)`/`requireAdmin(req,env)`/`buildAuthEngine(env)` gets `undefined` → TypeError → HTTP 500 for authed AND anon visitors. Correct: read runtime env from `import { env } from 'cloudflare:workers'` (or `Astro.locals.runtime.env`); NEVER bind `const env = Astro.env`.
Prevent: in any `.astro` page that needs runtime env, `grep 'Astro\.env' <file>` before commit — ZERO allowed; the `env` binding MUST come from the `cloudflare:workers` import. (ast rule `mod-no-astro-env` hard-gates `.ts/.tsx`; `.astro` frontmatter not yet hard-gated — slopgate ast engine doesn't parse `.astro`; stack regex pack proposed.)

### mod-cms-pnpm-filter-is-singular | fired:1 | 2026-06-24
`pnpm --filter @apps/mod-cms …` → wrong. Package name is `@app/mod-cms` (singular `app`); a non-matching `--filter` makes pnpm warn-and-exit-0, so the build/test/`astro check` it guards runs NOTHING and "passes" vacuously (false green). Correct: `pnpm --filter @app/mod-cms …`.
Prevent: before trusting any `--filter` result, confirm the exact name in `apps/mod-cms/package.json` (`@app/mod-cms`) — a green from a typo'd filter executed zero work.

### mod-cms-deploy-via-pnpm-wrapper-not-bare-wrangler | fired:1 | 2026-06-25
`npx wrangler deploy` (bare) to ship mod-cms → wrong. It skips `scripts/arm-after-deploy.mjs`, so scheduled posts silently never promote. The former local wrapper also let an un-gated checkout reach production.
Prevent: deploy mod-cms ONLY through `.github/workflows/delivery.yml` after `Main Gate` succeeds. NEVER run local `wrangler deploy`. Delivery deploys the tested SHA, then runs `arm-after-deploy.mjs` with production environment secrets. Confirm the workflow arm step logged `armed` — un-armed = scheduled-publish dead.

### mod-cms-comment-form-no-error-flash | filed:2026-06-26 | TODO
`CommentFormFields` island has no error / re-population path. On validation failure, `/api/comments` returns a bare 4xx — there is no redirect-with-flash. The island has no `errors`/`initialValues` props. This violates the Forms convention (inline error prose below the field). Fix requires: (1) `/api/comments` redirect-with-flash on validation failure (mirror `/api/contact` pattern); (2) parse flash params in `DefaultPostBody.astro`; (3) add `errors`/`initialValues` props to `CommentFormFields`. Pre-existing gap (predates the primitive-wiring refactor — not a regression). Deferring to a follow-up task.

### mod-cms-ui-primitives-require-tailwind-preflight | filed:2026-06-26 | UPSTREAM-TODO
`@platform-modules/ui-primitives` `Input` and `Textarea` use the bare `border` Tailwind utility (which needs Preflight's global `border-style: solid` default) and `px-*/py-*` spacing utilities (which need `--spacing` from Tailwind's theme layer). A consumer on utilities-only Tailwind (`@import 'tailwindcss/utilities'`) gets borderless, padding-collapsed inputs. This is a seam-level gap: the primitive silently requires the consumer to load full Preflight. Upstream fix: use explicit `border-solid` class OR document the Preflight requirement in the `@platform-modules/ui-primitives` package README + package.json `peerDependencies` note. mod-cms works around it by using `@import 'tailwindcss'` (full) in `site.css`.

### mod-cms-prose-restoration-coverage | filed:2026-06-26 | INVARIANT
Every set:html prose container that renders CMS-authored HTML (rich text from contentBody or
equivalent) MUST use one of the classes listed in the site.css restoration block OR use
class="cms-body". Preflight (loaded via @import 'tailwindcss' in site.css) resets list-style,
margins on blockquote, heading sizes, etc. for ALL elements on ALL pages. A bare <div
set:html=...> on any page that loads site.css ships broken lists/blockquotes.
Covered classes (as of 2026-06-26): .post-body, .comment__body, .editorial-post__body,
.editorial-post__comment-body, .magazine-post__body, .magazine-post__comment-body, .cms-body,
.prose.
When adding a NEW set:html prose container: either use class="cms-body" or add the new class
to EVERY selector group in the restoration block in site.css.
