···11+# Publication theme presets ("sky phases")
22+33+**Date:** 2026-06-09
44+**Status:** Approved (brainstorm) — ready for plan
55+**Area:** publication system (SP10), reader/renderer (SP4/SP7), brand (SP6)
66+77+## Summary
88+99+Let a publisher pick a colour theme for their publication from a small set of
1010+curated **sky-phase** presets. The chosen palette is stored on the publication
1111+record using the standard.site `basicTheme` field, surfaces on publication
1212+creation and in publication settings, and is applied when the publication and
1313+its articles are rendered publicly. Default is **no theme** (today's SkyPress
1414+look, unchanged).
1515+1616+## Motivation
1717+1818+standard.site's `site.standard.publication` lexicon defines an optional
1919+`basicTheme` (a `site.standard.theme.basic` object) so a publication can carry a
2020+visual identity that any standard.site reader can honour. SkyPress does not use
2121+it yet. Adding it lets publishers personalise their space, and — because we
2222+store the *standard* field rather than a SkyPress-private one — other readers
2323+(Leaflet, etc.) honour the same colours.
2424+2525+The Twenty Twenty-Five theme's colour style variations are named after times of
2626+day (evening, noon, dusk, afternoon, twilight, morning, sunrise, midnight). That
2727+is a direct fit for SkyPress's "open sky" brand and the existing `data-phase`
2828+attribute on `<html>`, so the presets ship as **sky phases**.
2929+3030+## Data model
3131+3232+### `site.standard.theme.basic` (embedded object)
3333+3434+Four required RGB colours (each `{ r, g, b }`, integers 0–255; alpha not used):
3535+3636+| field | meaning |
3737+|---|---|
3838+| `background` | content background |
3939+| `foreground` | content text |
4040+| `accent` | links + button backgrounds |
4141+| `accentForeground` | button text |
4242+4343+Represented in TypeScript as:
4444+4545+```ts
4646+interface Rgb { r: number; g: number; b: number; }
4747+interface BasicTheme {
4848+ $type: 'site.standard.theme.basic';
4949+ background: Rgb;
5050+ foreground: Rgb;
5151+ accent: Rgb;
5252+ accentForeground: Rgb;
5353+}
5454+```
5555+5656+### Changes to existing types (additions only — lexicon discipline)
5757+5858+- `PublicationRecord` (`src/lib/publish/records.ts`): add optional `basicTheme?: BasicTheme`.
5959+- `buildPublicationRecord`: accept + conditionally include `basicTheme`.
6060+- `PublicationInput` / `Publication` (`src/lib/publish/publications.ts`): add optional `basicTheme`.
6161+- `ReaderPublication` (`src/lib/reader/publications.ts`): surface `basicTheme`.
6262+6363+No field is ever removed or made required. `basicTheme` absent ⇒ default look.
6464+6565+## Presets — `src/lib/publish/themes.ts` (pure, tested)
6666+6767+A new dependency-free module exporting the 8 sky-phase presets derived from the
6868+Twenty Twenty-Five colour palettes:
6969+7070+```ts
7171+interface ThemePreset { slug: string; label: string; colors: BasicTheme; }
7272+export const THEME_PRESETS: readonly ThemePreset[];
7373+```
7474+7575+Mapping from each TT5 palette: `background ← base`, `foreground ← contrast`, and
7676+an `accent` / `accentForeground` pair chosen so button/link text clears
7777+**WCAG AA (contrast ≥ 4.5:1)**. The accent is the palette's most vivid usable
7878+accent; `accentForeground` is whichever of the palette's light/dark colours
7979+passes against it.
8080+8181+Phases (TT5 source): Evening, Noon, Dusk, Afternoon, Twilight, Morning,
8282+Sunrise, Midnight.
8383+8484+Helpers:
8585+- `findPresetByColors(theme): ThemePreset | null` — reverse-match stored colours
8686+ to a preset so the settings UI can highlight the current selection (returns
8787+ `null` for none/custom).
8888+- `themeToCssVars(theme): Record<string, string>` — map a `BasicTheme` to the
8989+ SkyPress design tokens it overrides. Pure, unit-tested.
9090+9191+## UI — preset picker in `PublicationForm`
9292+9393+A swatch-grid radio group added to `PublicationForm.tsx`, so it appears
9494+identically on **creation** and in the **Settings tab** (which already reuses
9595+`PublicationForm`). Each option is a small preview card showing the preset's
9696+background/text/accent, plus a "No theme" option (default, selected when the
9797+publication has no `basicTheme`).
9898+9999+**Hand-rolled React + plain CSS — not `@wordpress/components`.** AGENTS.md
100100+constraint #3 forbids `@wordpress/*` outside the editor/Studio island; the
101101+dashboard is an explicit no-`@wordpress` zone (importing it risks the duplicate
102102+`@wordpress/data` registry crash from Decision 0003). The picker matches the
103103+existing `PublicationForm` plain-React/CSS style.
104104+105105+Accessibility: a labelled radio group (`role="radiogroup"`), each preset an
106106+`<input type="radio">` with a visible label, keyboard-navigable, with a visible
107107+focus/selected state.
108108+109109+## Public rendering
110110+111111+In the publication-home (`[author]/[slug]/index.astro`) and article
112112+(`[author]/[slug]/[rkey].astro`) pages, when the resolved publication has a
113113+`basicTheme`, inject a small `<style>` into the existing `<Fragment slot="head">`
114114+that overrides the design tokens on `:root` via `themeToCssVars` (e.g.
115115+`--paper`←background, `--ink`←foreground, `--sun`/`--btn-primary`←accent,
116116+button text←accentForeground).
117117+118118+A publisher's palette is a **fixed identity**, so it intentionally overrides
119119+both the light and dark `prefers-color-scheme` defaults (standard.site's
120120+"consistent across platforms" intent). When there is no `basicTheme`, nothing is
121121+injected and the existing light/dark behaviour is untouched.
122122+123123+The injected values come from a trusted, app-controlled preset table keyed by
124124+the stored colours — but since the record is PDS-sourced, the renderer validates
125125+each channel is an integer 0–255 and emits only `rgb(...)` strings (never raw
126126+record text) to avoid CSS injection.
127127+128128+## Tests (TDD, failing-test-first)
129129+130130+- `themes.test.ts`: all 8 presets present; each accent/accentForeground pair
131131+ clears WCAG AA; `findPresetByColors` round-trips; `themeToCssVars` output.
132132+- `records.test.ts`: `buildPublicationRecord` includes `basicTheme` when given,
133133+ omits when not; RGB shape preserved.
134134+- `publications.test.ts`: create + update round-trip `basicTheme`.
135135+- `reader/publications.test.ts`: `basicTheme` surfaces (and malformed/absent →
136136+ undefined).
137137+- Renderer mapping covered by `themeToCssVars` test + channel validation test.
138138+- Picker: radio-group semantics / selection state.
139139+140140+## Docs & decisions
141141+142142+- New decision doc `docs/decisions/0012-publication-theme-presets.md`: why the
143143+ standard `basicTheme` field, why hand-rolled UI (not `@wordpress`), and the
144144+ light/dark override semantics.
145145+- Update `lexicons/README.md` — add `basicTheme` to the
146146+ `site.standard.publication` fields table.
147147+148148+## Out of scope (YAGNI)
149149+150150+- Custom colour pickers / arbitrary palettes (presets only).
151151+- Theming the authenticated dashboard/editor chrome (public reader only).
152152+- Fonts, layout/spacing, or per-article overrides.
153153+- `labels` / `preferences` publication fields.
154154+```