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>→ theErrNoDefault/ErrNotFoundsentinels (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 |
| — | 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¶
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:
- Pin the blog's themes into its
.keryx.yamlfirst, 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 thestorage:block to CI. The two are independent pieces of the same unblocking. - 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 foreditorialsuggested — 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: - There was no way to set a default at all.
Catalog.SetDefaultexisted but no CLI command called it andtheme addhad no flag — so removing the seed would have leftthemes.defaultsunsettable, 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. - A theme with no settings emitted invalid YAML (
first: {}}), because every field isomitempty. Verified pre-existing onmain, but far more likely now that a first theme has nothing to--fromclone.theme addrefuses it with a message naming the real problem instead of leaking the emitter's complaint about braces. - Move the user library from
~/.keryx/config.yamlto~/.keryx/themes.yaml. MOVED TO 0042 (Matt, 2026-07-27). Both this andaccounts.yamlneed the same machinery — contributing an extra user-scoped file as a config layer — which 0042 already specifies as aBackend. 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. - Add
theme promote+ provenance intheme list(§3.4). DONE 2026-07-27, ahead of step 3 (see the status note). Also addedtheme use, which step 2 turned out to require: removing the seed leftthemes.defaultssettable 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. - 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 —
promotecopies, 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 initseeds 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.yamland~/.keryx/themes.yamland 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 pinships. 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 withpromote(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
editorialkeeps the scenarios readable.
7. Test plan (sketch)¶
- Seed: a fresh
initwrites nothemes:block; generation with no theme fails with an actionable error namingtheme 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 listreports the scope each theme came from. - Config hygiene: writes land in
themes.yamland touch no other file — the same assertionTestPersist_writesOnlyThemesmakes, extended to the split.