← Devlog

m-003

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.