The era that changed underneath us
What was true before
The pipeline had to be built before there was anything to put through it, and the same problem kept turning up in a different costume: something visual was declared in one place and then copied into three others.
The site’s palette was six hex codes in a <style> block in apps/web/src/layouts/Page.astro. The
devlog’s code highlighting was the same six, typed out again in apps/web/astro.config.mjs. The
format guide said what a shot prompt has to state — amber #f5c542 on near-black #0d0d0d — which
was a fourth copy, in prose, where nothing could check it. And every shot prompt in every episode
restated it a fifth time, because that is what a shot prompt is for.
That is tolerable while there is one look. The moment a second look exists, four of those five copies are wrong and nothing fails. A test asserting the amber passes because it is comparing a constant against a constant. The site renders amber because the stylesheet says amber. Only a human looking at a page notices, and by then the wrong thing has been published.
The other half of the problem was the reverse: an episode published in the old look must stay in
the old look. It is a record of when it was made. Re-skinning m-001 to match a palette invented
three months later would be forging that record.
So the look needed to be one declaration that two surfaces read differently. The site always current. An episode frozen where it was made.
The decision, and the option that lost
An era is a typed constant in packages/content, and episodes carry its name.
src/eras.ts declares an array of eras. Each holds its palette named by role, its motif, and a
look sentence that a shot prompt has to state. The last one declared is the current one, because
eras only advance:
packages/content/src/eras.ts
export function currentEra(): Era {
const last = ERAS[ERAS.length - 1];
if (last === undefined) throw new Error("no eras declared");
return last;
}
No separate current pointer, which would be a second thing to keep in step for no gain.
Then an episode’s schema requires the era it was made in, and drafting stamps it without being asked:
packages/content/src/index.ts
/**
* The era an episode was made in.
*
* Required, and stamped at draft time. An episode is a record of when it was
* made, so it is read in its own era rather than the current one — and an
* episode drafted without a stamp cannot be placed once a second era exists,
* which is why this is not optional and not backfillable.
*/
export const eraName = z
.string()
.regex(/^[a-z][a-z0-9-]*$/, "an era is named in lowercase, like terminal");
The option that lost was a prompts-style file the tooling reads at runtime. The prompts in this
milestone are markdown files on disk with frontmatter, read and digested by src/prompts.ts, and
symmetry says the era should be one too.
It loses on the site. apps/web bundles @takelantern/content through Astro, and anything
resolving a path at runtime resolves it relative to the bundle rather than the repository. A prompt
is prose sent to a model, so a file is right for it. An era is six colours and a sentence, and a
typed constant is a reviewable diff as well — you can see a hex code change in a pull request
either way. The comment says so in the file, because the asymmetry looks like carelessness
otherwise:
packages/content/src/eras.ts
* A constant and not a file it reads: the site bundles this module, and
* anything resolving paths at runtime resolves them relative to the bundle.
* The prompts are files because they are prose sent to a model; an era is six
* colours and a sentence, and a typed constant is a reviewable diff too.
Someone could reasonably argue the other way — put it in a file and give the site a build-time import — and would have a smaller consistency argument on their side and a larger plumbing bill.
The code that shipped
The site’s palette becomes derived rather than declared. The layout emits the era’s colours as custom properties and the rest of the stylesheet reads those:
apps/web/src/layouts/Page.astro
import { currentEra, cssVariables } from "@takelantern/content/eras";
<style is:global set:html={`:root {\n${cssVariables(era)}\n}`}></style>
The devlog’s Shiki theme is built from the same three roles, and — this is the part that only mattered later — takes its polarity from the era too:
apps/web/astro.config.mjs
const theme = {
name: era.name,
type: era.ground,
colors: {
"editor.background": token("inset"),
"editor.foreground": token("fg"),
ground is not derivable from the colours without deciding what counts as dark, and the theme has
to state it, so the era says it:
packages/content/src/eras.ts
/**
* Which way round the palette runs — light ink on a dark ground, or dark
* ink on a light one. Not derivable from the colours without deciding what
* counts as dark, and the code theme has to state it, so the era says it.
*/
ground: "dark" | "light";
And the check that keeps an episode honest reads the episode’s own era, not the current one:
apps/studio/src/episodes.test.ts
it("states its own era's palette and the framing in every shot prompt", () => {
// Its own era, not the current one. An episode is a record of when it
// was made, so it stays checkable after the look advances — and would
// fail here if someone re-skinned an old one to match a new era.
const { colours } = era(episode.era);
Then the era advanced, and the seam was worth having. Era two is monochrome ink on paper, and its accent is the blackest mark rather than the brightest thing on screen:
packages/content/src/eras.ts
{
name: "engraving",
version: 2,
title: "Engraved ink on paper — the era the channel is in now.",
// Monochrome, and the polarity is the other way round from era one:
// hierarchy is depth of ink rather than brightness, so `accent` is the
// blackest mark on the page rather than the brightest thing on the screen.
Era one was left exactly as it was, so m-001 and m-002 still assert amber and still pass.
The site now wears the era’s marks rather than typing them. The wordmark replaces an <h1> of
styled text, read from the subject’s own committed reference:
apps/web/src/pages/index.astro
// The wordmark the channel uses, drawn once and read from the subject rather
// than set in the page's own type — the `>` in it is the era's motif.
import wordmark from "@takelantern/content/design/wordmark/reference/wordmark.png";
And because the marks are drawn as ink, an era with a dark ground has to invert them or its wordmark is black on black:
apps/web/src/layouts/Page.astro
:root[data-ground="dark"] .mark {
filter: invert(1);
}
What it cost
The palette test had to be rewritten or dropped, and we nearly got that wrong. A design subject’s prompt was asserted to contain its era’s accent and background hex codes. That is right for an era declared as colours and nonsense for one declared as ink — era two’s subjects are engraving prompts, and the three live references were drawn before the rule existed. Holding a hand-recorded prompt to a palette it was never drawn from only makes the record false. So the assertion narrowed to prompts the tool drafted:
apps/studio/src/designs.test.ts
for (const [name, body] of Object.entries(design.variants)) {
// A variant the tool drafted has to state the palette or the art
// comes back off-look. One recorded by hand is a record of an image
// that already exists — holding it to a palette it was never drawn
// from would only make the record false.
const record = provenance[name] as { by?: string } | undefined;
if (record?.by !== "prompt") continue;
A test asserting the word “framing” was asserting one spelling. Era two’s prompts say “framed square-on”, which says the same thing and failed. The fix is small and the shape of the bug is worth naming: the assertion was checking a token rather than the property the guide actually asks for.
apps/studio/src/episodes.test.ts
// GUIDE.md asks a shot prompt to state the framing, not to use one
// inflection of the word: "framed square-on" says it as plainly as
// "square-on framing". A prompt that says neither still fails.
assert.match(
shot.prompt,
/fram(ing|ed)\b/,
`shot ${shot.n} says nothing about how it is framed`,
);
A task’s title stopped matching its commit because of an apostrophe. TASK-103 is called
feat(studio): draft and redraft an era's design subjects. Backlog writes that into frontmatter as
single-quoted YAML with the apostrophe doubled, and the frontmatter reader stripped the quotes
without undoing the doubling. So the title read back as era''s, matched no commit subject, and the
task’s diff silently never reached the draft — while the article was written confidently around the
hole. The unescaping now happens where frontmatter is read, so every field gets it:
apps/studio/src/repository.ts
const raw = match[1]!.trim();
if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) {
return raw.slice(1, -1).replace(/''/g, "'");
}
That is the second time in this project a plan-to-history mapping has broken quietly. The first was
m-001, where eight tasks landed as three reworded commits and the milestone can no longer be
written up from its own history at all. This one was recoverable. It was also invisible until a
draft came back missing a task.
Three services are built and unproven. ElevenLabs, HeyGen and their request shapes have never been exercised against the live API, and each module says so in a comment at the top. Anthropic and Gemini are TASK-104’s job, and TASK-104 is the one task in this milestone still open. The record of what the first live run finds is owed.
Two acceptance criteria on TASK-017 stayed unchecked. The takelantern-content 1Password item
does not exist, so .env.production references it by name where every other group references its
item by ID. The file says so rather than pretending otherwise:
.env.production
# Content pipeline (m-003). Vault as above; the item is referenced by NAME because
# `takelantern-content` does not exist in 1Password yet and its ID cannot be known
# until it does. Swap each `takelantern-content` below for the item ID once it is
# created, so this file matches the ID-not-name rule the block above follows.
Both committed episodes are hand-written, and their provenance says so. No API key was available
in the environment they were drafted in, so every unit of m-001 and m-002 records "by": "hand"
with the reason rather than claiming a prompt version it never had. The provenance union exists
partly for this:
packages/content/src/episodes.ts
* A discriminated union rather than a prompt name with a sentinel value: a
* unit a person wrote has no version, no model and no context, and saying so
* in the shape is better than four empty fields and a magic string.
What it makes possible
The look is now a thing you change in one file. Era two landed as a new entry in ERAS, three
subject manifests naming it, and the site picked up its palette, its code theme’s polarity, its
favicon and its wordmark without a single hex code being retyped — while m-001 and m-002 went on
asserting amber, because they were made in it.