···11+# 0012 — Publication theme presets ("sky phases")
22+33+- **Status:** Accepted
44+- **Date:** 2026-06-09
55+- **Scope:** publication system (SP10), public reader (SP4/SP7), brand (SP6)
66+77+## Context
88+99+Publishers had no way to give a publication its own look — every SkyPress publication
1010+rendered with the single global brand palette. standard.site's `site.standard.publication`
1111+lexicon already defines an optional `basicTheme` (a `site.standard.theme.basic` object:
1212+`background`, `foreground`, `accent`, `accentForeground`, each an RGB triple) precisely so a
1313+publication can carry a visual identity that any standard.site reader can honour. SkyPress
1414+did not use it.
1515+1616+Open questions: whether to store the standard field or a SkyPress-private one; whether to
1717+offer curated presets or arbitrary colour pickers; what component library the picker should
1818+use; and how a chosen theme interacts with SkyPress's light/dark (`prefers-color-scheme`)
1919+design tokens.
2020+2121+## Decision
2222+2323+### 1. Store the standard `basicTheme` field, not a private one
2424+2525+The theme is written to `site.standard.publication.basicTheme` exactly as the community
2626+lexicon defines it. Because it is the *standard* field, other standard.site readers (Leaflet,
2727+etc.) honour the same colours — interop for free. This is an additions-only, optional field,
2828+consistent with the lexicon-discipline rule (AGENTS.md). The renderer treats stored colours as
2929+untrusted PDS data: `parseBasicTheme` validates every channel is an integer 0–255 and the
3030+renderer emits only app-built `rgb()` strings (AGENTS.md #6).
3131+3232+### 2. Curated presets only — eight "sky phases"
3333+3434+Rather than a free-form colour picker, SkyPress ships eight presets derived from the Twenty
3535+Twenty-Five theme's colour style variations, which are named after times of day (evening,
3636+noon, dusk, afternoon, twilight, morning, sunrise, midnight). That maps directly onto
3737+SkyPress's "open sky" brand and the existing `data-phase` attribute, so the feature reads as
3838+**sky phases**, not a generic theme editor. Each preset's accent/accentForeground pair is
3939+chosen so button/link text clears **WCAG AA (≥ 4.5:1)** — locked by a unit test
4040+(`themes.test.ts`). A custom colour picker is deliberately out of scope (YAGNI) and would also
4141+reopen the contrast-safety question per publication.
4242+4343+### 3. Hand-rolled React picker, not `@wordpress/components`
4444+4545+The picker lives in `PublicationForm` (so it appears on both creation and the settings tab)
4646+and is built from plain React + CSS matching the existing form. It does **not** use
4747+`@wordpress/components`, despite that being an option, because constraint #3 (Decision 0003)
4848+forbids importing `@wordpress/*` outside the editor/Studio island — the dashboard is a
4949+no-`@wordpress` zone, and pulling the editor stack in risks the duplicate `@wordpress/data`
5050+registry crash. The picker is a labelled radio group (`role="radiogroup"`), keyboard-navigable.
5151+5252+### 4. Pure, dependency-free theme module
5353+5454+`src/lib/publish/themes.ts` holds the `BasicTheme`/`Rgb` types, the presets, `parseBasicTheme`,
5555+`findPresetByColors` (reverse-match stored colours so the picker highlights the current one),
5656+and `themeToCssVars`/`themeStyleBlock` (token mapping + the injected `<style>`). It has no
5757+`@atproto`, `@wordpress`, or DOM dependency, so the SSR reader can import it on the read path.
5858+5959+### 5. A publisher theme overrides both light and dark
6060+6161+On the publication-home and article pages, when the publication has a `basicTheme` the page
6262+head injects a `<style>` overriding the design tokens (`--paper`←background, `--ink`←foreground,
6363+`--sun`/`--btn-primary`←accent, button text←accentForeground; borders/muted are deterministic
6464+blends so the editorial hierarchy survives any palette). Because a publisher's palette is a
6565+fixed identity (standard.site's "consistent across platforms" intent), it intentionally
6666+overrides both the light and dark `:root` defaults rather than only one. With no `basicTheme`,
6767+nothing is injected and the existing light/dark behaviour is untouched — the default for new
6868+publications is **no theme**.
6969+7070+To let button text follow `accentForeground`, the previously hardcoded `.btn--primary { color: #fff }`
7171+was tokenised to `var(--btn-primary-fg)` (default `#fff` in both palettes — a no-op for the
7272+default look).
7373+7474+## Consequences
7575+7676+- Themes are interoperable: other standard.site readers render the same palette.
7777+- The contrast guarantee is owned and tested here; adding a preset means adding its assertion.
7878+- The reader gains a tiny inline `<style>` per themed page; unthemed pages are unchanged.
7979+- Presets-only means no per-publication contrast risk and no custom-colour UI to maintain.
8080+8181+## Alternatives considered
8282+8383+- **SkyPress-private theme field** — rejected; loses cross-reader interop for no benefit.
8484+- **Arbitrary colour pickers** — rejected (YAGNI + per-publication contrast safety burden).
8585+- **`@wordpress/components` for the picker** — rejected; violates constraint #3 / Decision 0003.
8686+- **Theme only one of light/dark** — rejected; a publication identity should be stable
8787+ regardless of the reader's OS colour-scheme preference.
···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+```
+1
lexicons/README.md
···6262| `name` | ✓ | publication name (defaults from the handle) |
6363| `description` | | optional |
6464| `icon` | | optional blob (≤1MB) |
6565+| `basicTheme` | | optional `site.standard.theme.basic` object — a sky-phase colour theme (`background`/`foreground`/`accent`/`accentForeground` as RGB); applied on the public reader and honoured by other standard.site readers (Decision 0012) |
65666667## `app.bsky.feed.post` — the social signal
6768
+91
src/components/PublicationForm.test.tsx
···11+import { describe, it, expect } from 'vitest';
22+import { createElement } from 'react';
33+import { renderToStaticMarkup } from 'react-dom/server';
44+import type { Agent } from '@atproto/api';
55+import PublicationForm from './PublicationForm';
66+import { THEME_PRESETS } from '../lib/publish/themes';
77+import type { Publication } from '../lib/publish/publications';
88+99+const baseProps = {
1010+ agent: {} as Agent,
1111+ did: 'did:plc:alice',
1212+ pdsUrl: null,
1313+ handle: 'alice.test',
1414+ onSaved: () => {},
1515+ onCancel: () => {},
1616+};
1717+1818+function renderForm( existing?: Publication ): string {
1919+ return renderToStaticMarkup( createElement( PublicationForm, { ...baseProps, existing } ) );
2020+}
2121+2222+/** Every `<input type="radio">` tag in the markup. */
2323+function radios( markup: string ): string[] {
2424+ return markup.match( /<input[^>]*type="radio"[^>]*>/g ) ?? [];
2525+}
2626+2727+/** The `value=""` of the single checked radio, or null. */
2828+function checkedValue( markup: string ): string | null {
2929+ const checked = radios( markup ).find( ( tag ) => /\schecked\b/.test( tag ) );
3030+ if ( ! checked ) {
3131+ return null;
3232+ }
3333+ return checked.match( /value="([^"]*)"/ )?.[ 1 ] ?? null;
3434+}
3535+3636+describe( 'PublicationForm theme picker', () => {
3737+ it( 'renders a "no theme" option plus every preset as radios in one group', () => {
3838+ const markup = renderForm();
3939+ expect( markup ).toContain( 'role="radiogroup"' );
4040+ expect( markup ).toContain( 'aria-label="Theme"' );
4141+ expect( radios( markup ) ).toHaveLength( THEME_PRESETS.length + 1 );
4242+ for ( const preset of THEME_PRESETS ) {
4343+ expect( markup ).toContain( `value="${ preset.slug }"` );
4444+ expect( markup ).toContain( preset.label );
4545+ }
4646+ } );
4747+4848+ it( 'defaults a new publication to "no theme" (empty value checked)', () => {
4949+ expect( checkedValue( renderForm() ) ).toBe( '' );
5050+ } );
5151+5252+ it( 'pre-selects the matching preset when editing a themed publication', () => {
5353+ const existing: Publication = {
5454+ uri: 'at://x',
5555+ rkey: 'r',
5656+ slug: 'blog',
5757+ name: 'Blog',
5858+ basicTheme: THEME_PRESETS[ 2 ].colors, // dusk
5959+ };
6060+ expect( checkedValue( renderForm( existing ) ) ).toBe( 'dusk' );
6161+ } );
6262+6363+ it( 'surfaces and pre-selects a "Current" option when the stored theme matches no preset', () => {
6464+ // A valid theme whose colours match no preset (e.g. a preset whose values later changed).
6565+ // It must remain visible + selected so an unrelated edit doesn't silently erase it.
6666+ const existing: Publication = {
6767+ uri: 'at://x',
6868+ rkey: 'r',
6969+ slug: 'blog',
7070+ name: 'Blog',
7171+ basicTheme: {
7272+ $type: 'site.standard.theme.basic',
7373+ background: { r: 10, g: 20, b: 30 },
7474+ foreground: { r: 240, g: 240, b: 240 },
7575+ accent: { r: 100, g: 50, b: 200 },
7676+ accentForeground: { r: 255, g: 255, b: 255 },
7777+ },
7878+ };
7979+ const markup = renderForm( existing );
8080+ expect( markup ).toContain( 'Current' );
8181+ expect( radios( markup ) ).toHaveLength( THEME_PRESETS.length + 2 ); // none + custom + presets
8282+ expect( checkedValue( markup ) ).toBe( 'custom' );
8383+ } );
8484+8585+ it( 'shows no "Current" option for an unthemed publication', () => {
8686+ const existing: Publication = { uri: 'at://x', rkey: 'r', slug: 'blog', name: 'Blog' };
8787+ const markup = renderForm( existing );
8888+ expect( radios( markup ) ).toHaveLength( THEME_PRESETS.length + 1 );
8989+ expect( checkedValue( markup ) ).toBe( '' );
9090+ } );
9191+} );
···11+/**
22+ * Regression guard for the publication-theme injection (Decision 0012).
33+ *
44+ * The publication-home and article pages inject a `<style>` overriding the design tokens when a
55+ * publication has a `basicTheme`. Rendering these `.astro` pages through astro/container isn't
66+ * viable here (the runner is pinned to jsdom for the WordPress block suites, which breaks
77+ * esbuild's init invariant — see index.phase.test.ts), so these asserts pin the wiring at the
88+ * source level: each page must compute `themeStyleBlock(publication.basicTheme)` and inject it,
99+ * gated on a truthy result, via `set:html` (the value is app-built CSS, proven injection-safe by
1010+ * themes.test.ts). A refactor that drops the gate or passes the wrong field ships green without it.
1111+ *
1212+ * Lives under src/lib/ (not src/pages/) on purpose: a `.test.ts` inside a `[param]` route dir is
1313+ * treated by Astro as a prerendered endpoint and breaks the build.
1414+ */
1515+import { readFileSync } from 'node:fs';
1616+import { fileURLToPath } from 'node:url';
1717+import { describe, expect, it } from 'vitest';
1818+1919+const read = ( rel: string ) =>
2020+ readFileSync( fileURLToPath( new URL( rel, import.meta.url ) ), 'utf8' );
2121+2222+describe( 'publication theme injection wiring', () => {
2323+ for ( const [ label, file ] of [
2424+ [ 'publication home', '../../pages/[author]/[slug]/index.astro' ],
2525+ [ 'article', '../../pages/[author]/[slug]/[rkey].astro' ],
2626+ ] as const ) {
2727+ describe( label, () => {
2828+ const src = read( file );
2929+3030+ it( 'imports themeStyleBlock from the themes module', () => {
3131+ expect( src ).toMatch(
3232+ /import\s*\{\s*themeStyleBlock\s*\}\s*from\s*['"][^'"]*publish\/themes['"]/
3333+ );
3434+ } );
3535+3636+ it( 'derives the style block from the publication’s basicTheme', () => {
3737+ expect( src ).toMatch( /themeStyleBlock\(\s*publication\.basicTheme\s*\)/ );
3838+ } );
3939+4040+ it( 'injects it via set:html, gated on a truthy theme', () => {
4141+ expect( src ).toMatch(
4242+ /\{\s*themeStyle\s*&&\s*<Fragment\s+set:html=\{\s*themeStyle\s*\}\s*\/>\s*\}/
4343+ );
4444+ } );
4545+ } );
4646+ }
4747+} );