Skip to content

0046 — Theme scopes: get themes out of the seed

Status: STEPS 1, 2, 4 IMPLEMENTED 2026-07-27. Step 3 moved to 0042 (one user-config split, see below); step 5 belongs to 0037. Nothing else outstanding here. Originally: steps 1–2 and 4 of §4 done 2026-07-27: the blog's themes are pinned into its .keryx.yaml, and the seed ships no themes: block. Remaining: step 3 (move the user library to ~/.keryx/themes.yaml) and step 5 (studio).

Step 4 was done before step 3, deliberately. Promotion works against whatever the user layer is today; the themes.yaml split changes where that layer is, not whether promotion works. Doing it first delivered the capability (and unstranded emberlight), and the split shares its layer-adding machinery with 0042, so the two are better done together. Supersedes this spec's original framing (a seed reconcile), which assumed the shipped seed was the distribution channel for themes. It should not be one. From issue #5. Date: 2026-07-27 Relates: 0001 §6 (themes are config-driven — amended here) · 0014 (per-project config layer) · 0035 (structure-preserving writes, provenance) · 0042 D6 (the same file-splitting reasoning, for credentials) · issue #5.


1. How this spec changed

Issue #5 asked for a way to pull newly-shipped seed themes into an existing config. Tracing where the eight article themes actually live showed the framing was wrong:

article themes
Matt's global ~/.keryx/config.yaml 10 — including all 7 new ones
The shipped seed 3 (editorial, clay, blueprint)
The blog's project .keryx.yaml 3

The new themes were hand-added to global only. So a seed reconcile would have distributed nothing, and its opt-in --prune would have offered to delete all seven as "not in the seed". The command was pointed the wrong way round.

The deeper objection (Matt): the seed is code; themes are content. The seed ships in the binary, is versioned and released. Putting a style you are experimenting with into it means a keryx release to distribute a whim, and every install of a reusable tool inheriting one project's aesthetic.

2. The finding that makes this safe

keryx has no programmatic knowledge of any theme. Verified 2026-07-27:

  • No Go source names editorial, clay, blueprint, or any keyword (non-test).
  • Resolution is entirely data-driven: --theme <kw>themes.defaults.<type> → the ErrNoDefault / ErrNotFound sentinels (internal/theme/catalog.go).

So themes were in the seed purely for convenience and parity with the Python scripts. Nothing breaks structurally by removing them; what changes is what a fresh install has.

3. Design

3.1 Three scopes, and the missing directions

Scope Home Purpose
Project <project>/.keryx.yaml the themes this project depends on — committed, so CI has them
User ~/.keryx/themes.yaml the human's style library, shared across their projects
Seed removed (§3.2)

Global themes already layer under a project's file (projectconfig.go:36), so user → project resolution works today. The missing direction is promotion: author a theme in a project, against a real reel, then lift it into the library.

3.2 The seed ships no themes

pkg/cmd/root/assets/init/config.yaml loses the whole themes: block — including themes.defaults, which would otherwise dangle pointing at a theme that no longer exists.

A fresh install therefore has no themes and generation fails with ErrNoDefault until the user brings or authors one. That is accepted (D1): keryx is installed into a project that brings its own config, and a tool that ships one project's taste to every install is worse than a tool that ships none. The error must say what to do rather than being a bare sentinel.

3.3 A dedicated ~/.keryx/themes.yaml

The style library gets its own file, for the same reasons accounts.yaml does in 0042 D6: one job per file, separately diffable, backed up and carried between machines without dragging server ports and update settings along.

~/.keryx/config.yaml     tool settings — ports, update, git behaviour
~/.keryx/themes.yaml     the style library
~/.keryx/accounts.yaml   credentials (0042 D6)

3.4 Promotion

keryx theme promote <keyword> [--type reel|article|portrait]

Copies a theme from the active project's .keryx.yaml into ~/.keryx/themes.yaml, so it is available to every project. Copy, not move: the project keeps its committed copy, which is what CI reads.

theme list gains provenance — project / user — answering "where does this come from?", which is currently unanswerable and is the question you would ask before promoting anything. go/config already supports this per key (View.Origin / View.Explain), so it is a presentation change, not new machinery.

3.5 Studio

The Settings theme editor currently writes to one place. With the split it must show which scope a theme comes from and offer promotion, mirroring the CLI. Scope for that lands with 0037's settings IA rather than being invented here.

4. Order of operations — this matters

Doing these in the wrong order breaks the blog, whose CI reads only the project config:

  1. Pin the blog's themes into its .keryx.yaml first, by hand. The blog then depends on nothing ambient. (Nothing else may proceed before this.)

    This alone does not fix blog CI. That file is currently gitignored because it carries live OAuth tokens (blog MR !120), so CI still cannot read it. Pinning removes the dependency on ambient global config; 0042 (move the credentials to ~/.keryx/accounts.yaml, then lift the ignore) is what actually gets the themes and the storage: block to CI. The two are independent pieces of the same unblocking.

  2. Remove the themes: block from the seed (§3.2) + update the e2e fixtures. DONE 2026-07-27. Only 3 scenarios actually depended on the seeded themes, not the 11 a grep for editorial suggested — most uses only record a keyword without resolving it. The suite now creates what it needs (Given the house reel themes "..."), which is more honest than depending on shipped content. Two gaps surfaced while doing it, both fixed rather than left as traps:
  3. There was no way to set a default at all. Catalog.SetDefault existed but no CLI command called it and theme add had no flag — so removing the seed would have left themes.defaults unsettable, and D1's "actionable" error pointing at an action that did not exist. The first theme of a type now becomes its default; an existing default is never taken over.
  4. A theme with no settings emitted invalid YAML (first: {}}), because every field is omitempty. Verified pre-existing on main, but far more likely now that a first theme has nothing to --from clone. theme add refuses it with a message naming the real problem instead of leaking the emitter's complaint about braces.
  5. Move the user library from ~/.keryx/config.yaml to ~/.keryx/themes.yaml. MOVED TO 0042 (Matt, 2026-07-27). Both this and accounts.yaml need the same machinery — contributing an extra user-scoped file as a config layer — which 0042 already specifies as a Backend. Doing them separately means building it twice, so they ship as one "split the user config into purposeful files" change. Nothing here depends on it: themes resolve identically either way, so this is organisation, not capability.
  6. Add theme promote + provenance in theme list (§3.4). DONE 2026-07-27, ahead of step 3 (see the status note). Also added theme use, which step 2 turned out to require: removing the seed left themes.defaults settable only as a side-effect of adding the first theme of a type. Provenance had to account for a theme defined in BOTH scopes — after a promote, some of its leaves resolve from the user copy and some from the project's, so asking an arbitrary leaf gives the wrong answer.
  7. Studio (§3.5) — belongs to 0037's settings IA, not invented here.

5. Decisions

  • D1 — A fresh install ships no themes and does nothing to compensate (Matt, 2026-07-27). No starter import, no opt-in house bundle. The error must be actionable.
  • D2 — The blog is protected by hand first (§4 step 1), before anything is removed from the seed.
  • D3 — promote copies, it does not move. The project keeps the committed copy CI depends on.
  • D4 — This amends 0001 §6 and CLAUDE.md, both of which state that keryx init seeds the house themes at parity with the Python scripts. That is no longer true and both must be updated in the same change, so it does not read later as drift.

6. Open questions — FOR MATT

  • Q1 — RESOLVED (Matt, 2026-07-27): no copy in the repo. After step 1 the house themes exist in the blog's .keryx.yaml and ~/.keryx/themes.yaml and nowhere else. Backing up ~/.keryx/ is the user's responsibility, which is the honest division: keryx is a tool, not a vault for someone's style library.
  • Q2 — RESOLVED (Matt, 2026-07-27): yes, theme pin ships. Implemented 2026-07-27. It copies from the user layer specifically, not the resolved catalog: once pinned, a theme exists in both scopes and the project's copy wins, so copying the resolved theme would write the project's own version back over itself — a no-op dressed as a refresh. That asymmetry with promote (which copies the winner) is the only subtle part.
  • Q3 — RESOLVED (Matt, 2026-07-27): an explicit command, not an automatic move. keryx notices themes in the old location and says to run it; it reports what moved. Silently rewriting two of the user's config files on first run would be a surprising thing for a tool to do to files the user owns. Lands with the 0042 split.
  • Q4 — What do the e2e fixtures seed? They need a theme. A fixture-local one is more honest (the suite stops depending on shipped content); reusing the house editorial keeps the scenarios readable.

7. Test plan (sketch)

  • Seed: a fresh init writes no themes: block; generation with no theme fails with an actionable error naming theme add.
  • Scopes: a project theme resolves; a user theme resolves; a project theme of the same keyword wins over the user one.
  • Promote: copies project → user, leaves the project copy intact, is idempotent, and refuses when the keyword already exists in the user library unless told to overwrite.
  • Provenance: theme list reports the scope each theme came from.
  • Config hygiene: writes land in themes.yaml and touch no other file — the same assertion TestPersist_writesOnlyThemes makes, extended to the split.