10. Centralized copy.json for All User-Facing Strings

Status

Accepted — 2026-02-17

Context

The site’s satirical voice lives in its copy. Taglines, nav labels, empty states, error messages, button text, and microcopy all carry the tone. When strings are inlined in templates and components, tone review requires touching every file, and drift is hard to detect. A reader scanning the site cold does not know which strings were written with intent and which were typed into a template to make the error go away. The ernest-sludge-style-guardian agent can only enforce voice on content it can find and read coherently.

Decision

All user-facing strings live in frontend/src/app/content/copy.json, a single nested JSON file keyed by surface and component. Components import the typed COPY object via the @app/content path alias and reference string paths at usage sites.

  • copy.ts re-exports copy.json and derives an AppCopy type from it, so references are type-checked at compile time.
  • The file is hand-authored, not generated. Keys reflect the surface (shell.header.taglines, landing.hero.headline, shell.footer.classification) rather than feature-internal identifiers.
  • Strings used in prerendered markdown (post bodies) are authored in markdown files under content/ and are outside this ADR’s scope. The pipeline handles those.

Consequences

  • Every visible string is enumerable from one file. Tone audits by ernest or ash read a single source instead of grepping the codebase.
  • Type safety on string access. A typo in COPY.shell.header.tagline (missing s) fails the build rather than rendering undefined.
  • The structure resembles an i18n setup without being one. If internationalization is ever wanted, the nested-key shape is already compatible with most i18n tools; if it is not wanted, nothing from i18n tooling is paying its cost.
  • Every new feature has a copy step. Components cannot ship default framework strings or placeholder text without first landing a key in copy.json. This is the point, not a friction to engineer around.
  • A single file for all strings grows monotonically. At the current scale this is a benefit (single source of truth); at much larger scale it could warrant splitting by surface — but splitting would forfeit the single-file audit property.
  • Alternatives-per-rotation (e.g., the taglines array) live in the data, not in component logic. Randomization is a view concern; the content is still centrally reviewable.