A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'theme-presets-layout-picker' into trunk

+1106 -3
+87
docs/decisions/0012-publication-theme-presets.md
··· 1 + # 0012 — Publication theme presets ("sky phases") 2 + 3 + - **Status:** Accepted 4 + - **Date:** 2026-06-09 5 + - **Scope:** publication system (SP10), public reader (SP4/SP7), brand (SP6) 6 + 7 + ## Context 8 + 9 + Publishers had no way to give a publication its own look — every SkyPress publication 10 + rendered with the single global brand palette. standard.site's `site.standard.publication` 11 + lexicon already defines an optional `basicTheme` (a `site.standard.theme.basic` object: 12 + `background`, `foreground`, `accent`, `accentForeground`, each an RGB triple) precisely so a 13 + publication can carry a visual identity that any standard.site reader can honour. SkyPress 14 + did not use it. 15 + 16 + Open questions: whether to store the standard field or a SkyPress-private one; whether to 17 + offer curated presets or arbitrary colour pickers; what component library the picker should 18 + use; and how a chosen theme interacts with SkyPress's light/dark (`prefers-color-scheme`) 19 + design tokens. 20 + 21 + ## Decision 22 + 23 + ### 1. Store the standard `basicTheme` field, not a private one 24 + 25 + The theme is written to `site.standard.publication.basicTheme` exactly as the community 26 + lexicon defines it. Because it is the *standard* field, other standard.site readers (Leaflet, 27 + etc.) honour the same colours — interop for free. This is an additions-only, optional field, 28 + consistent with the lexicon-discipline rule (AGENTS.md). The renderer treats stored colours as 29 + untrusted PDS data: `parseBasicTheme` validates every channel is an integer 0–255 and the 30 + renderer emits only app-built `rgb()` strings (AGENTS.md #6). 31 + 32 + ### 2. Curated presets only — eight "sky phases" 33 + 34 + Rather than a free-form colour picker, SkyPress ships eight presets derived from the Twenty 35 + Twenty-Five theme's colour style variations, which are named after times of day (evening, 36 + noon, dusk, afternoon, twilight, morning, sunrise, midnight). That maps directly onto 37 + SkyPress's "open sky" brand and the existing `data-phase` attribute, so the feature reads as 38 + **sky phases**, not a generic theme editor. Each preset's accent/accentForeground pair is 39 + chosen so button/link text clears **WCAG AA (≥ 4.5:1)** — locked by a unit test 40 + (`themes.test.ts`). A custom colour picker is deliberately out of scope (YAGNI) and would also 41 + reopen the contrast-safety question per publication. 42 + 43 + ### 3. Hand-rolled React picker, not `@wordpress/components` 44 + 45 + The picker lives in `PublicationForm` (so it appears on both creation and the settings tab) 46 + and is built from plain React + CSS matching the existing form. It does **not** use 47 + `@wordpress/components`, despite that being an option, because constraint #3 (Decision 0003) 48 + forbids importing `@wordpress/*` outside the editor/Studio island — the dashboard is a 49 + no-`@wordpress` zone, and pulling the editor stack in risks the duplicate `@wordpress/data` 50 + registry crash. The picker is a labelled radio group (`role="radiogroup"`), keyboard-navigable. 51 + 52 + ### 4. Pure, dependency-free theme module 53 + 54 + `src/lib/publish/themes.ts` holds the `BasicTheme`/`Rgb` types, the presets, `parseBasicTheme`, 55 + `findPresetByColors` (reverse-match stored colours so the picker highlights the current one), 56 + and `themeToCssVars`/`themeStyleBlock` (token mapping + the injected `<style>`). It has no 57 + `@atproto`, `@wordpress`, or DOM dependency, so the SSR reader can import it on the read path. 58 + 59 + ### 5. A publisher theme overrides both light and dark 60 + 61 + On the publication-home and article pages, when the publication has a `basicTheme` the page 62 + head injects a `<style>` overriding the design tokens (`--paper`←background, `--ink`←foreground, 63 + `--sun`/`--btn-primary`←accent, button text←accentForeground; borders/muted are deterministic 64 + blends so the editorial hierarchy survives any palette). Because a publisher's palette is a 65 + fixed identity (standard.site's "consistent across platforms" intent), it intentionally 66 + overrides both the light and dark `:root` defaults rather than only one. With no `basicTheme`, 67 + nothing is injected and the existing light/dark behaviour is untouched — the default for new 68 + publications is **no theme**. 69 + 70 + To let button text follow `accentForeground`, the previously hardcoded `.btn--primary { color: #fff }` 71 + was tokenised to `var(--btn-primary-fg)` (default `#fff` in both palettes — a no-op for the 72 + default look). 73 + 74 + ## Consequences 75 + 76 + - Themes are interoperable: other standard.site readers render the same palette. 77 + - The contrast guarantee is owned and tested here; adding a preset means adding its assertion. 78 + - The reader gains a tiny inline `<style>` per themed page; unthemed pages are unchanged. 79 + - Presets-only means no per-publication contrast risk and no custom-colour UI to maintain. 80 + 81 + ## Alternatives considered 82 + 83 + - **SkyPress-private theme field** — rejected; loses cross-reader interop for no benefit. 84 + - **Arbitrary colour pickers** — rejected (YAGNI + per-publication contrast safety burden). 85 + - **`@wordpress/components` for the picker** — rejected; violates constraint #3 / Decision 0003. 86 + - **Theme only one of light/dark** — rejected; a publication identity should be stable 87 + regardless of the reader's OS colour-scheme preference.
+154
docs/superpowers/specs/2026-06-09-publication-themes-design.md
··· 1 + # Publication theme presets ("sky phases") 2 + 3 + **Date:** 2026-06-09 4 + **Status:** Approved (brainstorm) — ready for plan 5 + **Area:** publication system (SP10), reader/renderer (SP4/SP7), brand (SP6) 6 + 7 + ## Summary 8 + 9 + Let a publisher pick a colour theme for their publication from a small set of 10 + curated **sky-phase** presets. The chosen palette is stored on the publication 11 + record using the standard.site `basicTheme` field, surfaces on publication 12 + creation and in publication settings, and is applied when the publication and 13 + its articles are rendered publicly. Default is **no theme** (today's SkyPress 14 + look, unchanged). 15 + 16 + ## Motivation 17 + 18 + standard.site's `site.standard.publication` lexicon defines an optional 19 + `basicTheme` (a `site.standard.theme.basic` object) so a publication can carry a 20 + visual identity that any standard.site reader can honour. SkyPress does not use 21 + it yet. Adding it lets publishers personalise their space, and — because we 22 + store the *standard* field rather than a SkyPress-private one — other readers 23 + (Leaflet, etc.) honour the same colours. 24 + 25 + The Twenty Twenty-Five theme's colour style variations are named after times of 26 + day (evening, noon, dusk, afternoon, twilight, morning, sunrise, midnight). That 27 + is a direct fit for SkyPress's "open sky" brand and the existing `data-phase` 28 + attribute on `<html>`, so the presets ship as **sky phases**. 29 + 30 + ## Data model 31 + 32 + ### `site.standard.theme.basic` (embedded object) 33 + 34 + Four required RGB colours (each `{ r, g, b }`, integers 0–255; alpha not used): 35 + 36 + | field | meaning | 37 + |---|---| 38 + | `background` | content background | 39 + | `foreground` | content text | 40 + | `accent` | links + button backgrounds | 41 + | `accentForeground` | button text | 42 + 43 + Represented in TypeScript as: 44 + 45 + ```ts 46 + interface Rgb { r: number; g: number; b: number; } 47 + interface BasicTheme { 48 + $type: 'site.standard.theme.basic'; 49 + background: Rgb; 50 + foreground: Rgb; 51 + accent: Rgb; 52 + accentForeground: Rgb; 53 + } 54 + ``` 55 + 56 + ### Changes to existing types (additions only — lexicon discipline) 57 + 58 + - `PublicationRecord` (`src/lib/publish/records.ts`): add optional `basicTheme?: BasicTheme`. 59 + - `buildPublicationRecord`: accept + conditionally include `basicTheme`. 60 + - `PublicationInput` / `Publication` (`src/lib/publish/publications.ts`): add optional `basicTheme`. 61 + - `ReaderPublication` (`src/lib/reader/publications.ts`): surface `basicTheme`. 62 + 63 + No field is ever removed or made required. `basicTheme` absent ⇒ default look. 64 + 65 + ## Presets — `src/lib/publish/themes.ts` (pure, tested) 66 + 67 + A new dependency-free module exporting the 8 sky-phase presets derived from the 68 + Twenty Twenty-Five colour palettes: 69 + 70 + ```ts 71 + interface ThemePreset { slug: string; label: string; colors: BasicTheme; } 72 + export const THEME_PRESETS: readonly ThemePreset[]; 73 + ``` 74 + 75 + Mapping from each TT5 palette: `background ← base`, `foreground ← contrast`, and 76 + an `accent` / `accentForeground` pair chosen so button/link text clears 77 + **WCAG AA (contrast ≥ 4.5:1)**. The accent is the palette's most vivid usable 78 + accent; `accentForeground` is whichever of the palette's light/dark colours 79 + passes against it. 80 + 81 + Phases (TT5 source): Evening, Noon, Dusk, Afternoon, Twilight, Morning, 82 + Sunrise, Midnight. 83 + 84 + Helpers: 85 + - `findPresetByColors(theme): ThemePreset | null` — reverse-match stored colours 86 + to a preset so the settings UI can highlight the current selection (returns 87 + `null` for none/custom). 88 + - `themeToCssVars(theme): Record<string, string>` — map a `BasicTheme` to the 89 + SkyPress design tokens it overrides. Pure, unit-tested. 90 + 91 + ## UI — preset picker in `PublicationForm` 92 + 93 + A swatch-grid radio group added to `PublicationForm.tsx`, so it appears 94 + identically on **creation** and in the **Settings tab** (which already reuses 95 + `PublicationForm`). Each option is a small preview card showing the preset's 96 + background/text/accent, plus a "No theme" option (default, selected when the 97 + publication has no `basicTheme`). 98 + 99 + **Hand-rolled React + plain CSS — not `@wordpress/components`.** AGENTS.md 100 + constraint #3 forbids `@wordpress/*` outside the editor/Studio island; the 101 + dashboard is an explicit no-`@wordpress` zone (importing it risks the duplicate 102 + `@wordpress/data` registry crash from Decision 0003). The picker matches the 103 + existing `PublicationForm` plain-React/CSS style. 104 + 105 + Accessibility: a labelled radio group (`role="radiogroup"`), each preset an 106 + `<input type="radio">` with a visible label, keyboard-navigable, with a visible 107 + focus/selected state. 108 + 109 + ## Public rendering 110 + 111 + In the publication-home (`[author]/[slug]/index.astro`) and article 112 + (`[author]/[slug]/[rkey].astro`) pages, when the resolved publication has a 113 + `basicTheme`, inject a small `<style>` into the existing `<Fragment slot="head">` 114 + that overrides the design tokens on `:root` via `themeToCssVars` (e.g. 115 + `--paper`←background, `--ink`←foreground, `--sun`/`--btn-primary`←accent, 116 + button text←accentForeground). 117 + 118 + A publisher's palette is a **fixed identity**, so it intentionally overrides 119 + both the light and dark `prefers-color-scheme` defaults (standard.site's 120 + "consistent across platforms" intent). When there is no `basicTheme`, nothing is 121 + injected and the existing light/dark behaviour is untouched. 122 + 123 + The injected values come from a trusted, app-controlled preset table keyed by 124 + the stored colours — but since the record is PDS-sourced, the renderer validates 125 + each channel is an integer 0–255 and emits only `rgb(...)` strings (never raw 126 + record text) to avoid CSS injection. 127 + 128 + ## Tests (TDD, failing-test-first) 129 + 130 + - `themes.test.ts`: all 8 presets present; each accent/accentForeground pair 131 + clears WCAG AA; `findPresetByColors` round-trips; `themeToCssVars` output. 132 + - `records.test.ts`: `buildPublicationRecord` includes `basicTheme` when given, 133 + omits when not; RGB shape preserved. 134 + - `publications.test.ts`: create + update round-trip `basicTheme`. 135 + - `reader/publications.test.ts`: `basicTheme` surfaces (and malformed/absent → 136 + undefined). 137 + - Renderer mapping covered by `themeToCssVars` test + channel validation test. 138 + - Picker: radio-group semantics / selection state. 139 + 140 + ## Docs & decisions 141 + 142 + - New decision doc `docs/decisions/0012-publication-theme-presets.md`: why the 143 + standard `basicTheme` field, why hand-rolled UI (not `@wordpress`), and the 144 + light/dark override semantics. 145 + - Update `lexicons/README.md` — add `basicTheme` to the 146 + `site.standard.publication` fields table. 147 + 148 + ## Out of scope (YAGNI) 149 + 150 + - Custom colour pickers / arbitrary palettes (presets only). 151 + - Theming the authenticated dashboard/editor chrome (public reader only). 152 + - Fonts, layout/spacing, or per-article overrides. 153 + - `labels` / `preferences` publication fields. 154 + ```
+1
lexicons/README.md
··· 62 62 | `name` | ✓ | publication name (defaults from the handle) | 63 63 | `description` | | optional | 64 64 | `icon` | | optional blob (≤1MB) | 65 + | `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) | 65 66 66 67 ## `app.bsky.feed.post` — the social signal 67 68
+91
src/components/PublicationForm.test.tsx
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { createElement } from 'react'; 3 + import { renderToStaticMarkup } from 'react-dom/server'; 4 + import type { Agent } from '@atproto/api'; 5 + import PublicationForm from './PublicationForm'; 6 + import { THEME_PRESETS } from '../lib/publish/themes'; 7 + import type { Publication } from '../lib/publish/publications'; 8 + 9 + const baseProps = { 10 + agent: {} as Agent, 11 + did: 'did:plc:alice', 12 + pdsUrl: null, 13 + handle: 'alice.test', 14 + onSaved: () => {}, 15 + onCancel: () => {}, 16 + }; 17 + 18 + function renderForm( existing?: Publication ): string { 19 + return renderToStaticMarkup( createElement( PublicationForm, { ...baseProps, existing } ) ); 20 + } 21 + 22 + /** Every `<input type="radio">` tag in the markup. */ 23 + function radios( markup: string ): string[] { 24 + return markup.match( /<input[^>]*type="radio"[^>]*>/g ) ?? []; 25 + } 26 + 27 + /** The `value=""` of the single checked radio, or null. */ 28 + function checkedValue( markup: string ): string | null { 29 + const checked = radios( markup ).find( ( tag ) => /\schecked\b/.test( tag ) ); 30 + if ( ! checked ) { 31 + return null; 32 + } 33 + return checked.match( /value="([^"]*)"/ )?.[ 1 ] ?? null; 34 + } 35 + 36 + describe( 'PublicationForm theme picker', () => { 37 + it( 'renders a "no theme" option plus every preset as radios in one group', () => { 38 + const markup = renderForm(); 39 + expect( markup ).toContain( 'role="radiogroup"' ); 40 + expect( markup ).toContain( 'aria-label="Theme"' ); 41 + expect( radios( markup ) ).toHaveLength( THEME_PRESETS.length + 1 ); 42 + for ( const preset of THEME_PRESETS ) { 43 + expect( markup ).toContain( `value="${ preset.slug }"` ); 44 + expect( markup ).toContain( preset.label ); 45 + } 46 + } ); 47 + 48 + it( 'defaults a new publication to "no theme" (empty value checked)', () => { 49 + expect( checkedValue( renderForm() ) ).toBe( '' ); 50 + } ); 51 + 52 + it( 'pre-selects the matching preset when editing a themed publication', () => { 53 + const existing: Publication = { 54 + uri: 'at://x', 55 + rkey: 'r', 56 + slug: 'blog', 57 + name: 'Blog', 58 + basicTheme: THEME_PRESETS[ 2 ].colors, // dusk 59 + }; 60 + expect( checkedValue( renderForm( existing ) ) ).toBe( 'dusk' ); 61 + } ); 62 + 63 + it( 'surfaces and pre-selects a "Current" option when the stored theme matches no preset', () => { 64 + // A valid theme whose colours match no preset (e.g. a preset whose values later changed). 65 + // It must remain visible + selected so an unrelated edit doesn't silently erase it. 66 + const existing: Publication = { 67 + uri: 'at://x', 68 + rkey: 'r', 69 + slug: 'blog', 70 + name: 'Blog', 71 + basicTheme: { 72 + $type: 'site.standard.theme.basic', 73 + background: { r: 10, g: 20, b: 30 }, 74 + foreground: { r: 240, g: 240, b: 240 }, 75 + accent: { r: 100, g: 50, b: 200 }, 76 + accentForeground: { r: 255, g: 255, b: 255 }, 77 + }, 78 + }; 79 + const markup = renderForm( existing ); 80 + expect( markup ).toContain( 'Current' ); 81 + expect( radios( markup ) ).toHaveLength( THEME_PRESETS.length + 2 ); // none + custom + presets 82 + expect( checkedValue( markup ) ).toBe( 'custom' ); 83 + } ); 84 + 85 + it( 'shows no "Current" option for an unthemed publication', () => { 86 + const existing: Publication = { uri: 'at://x', rkey: 'r', slug: 'blog', name: 'Blog' }; 87 + const markup = renderForm( existing ); 88 + expect( radios( markup ) ).toHaveLength( THEME_PRESETS.length + 1 ); 89 + expect( checkedValue( markup ) ).toBe( '' ); 90 + } ); 91 + } );
+112
src/components/PublicationForm.tsx
··· 11 11 PUBLICATION_ICON_MAX_BYTES, 12 12 } from '../lib/media/uploadImage'; 13 13 import { buildGetBlobUrl, type BlobRefJson } from '../lib/media/blob'; 14 + import { 15 + THEME_PRESETS, 16 + findPresetByColors, 17 + resolveSelectedTheme, 18 + CUSTOM_THEME_SLUG, 19 + type Rgb, 20 + } from '../lib/publish/themes'; 14 21 15 22 interface Props { 16 23 agent: Agent; ··· 48 55 ? buildGetBlobUrl( pdsUrl, did, existing.icon.ref.$link ) 49 56 : null 50 57 ); 58 + // A stored theme that matches no current preset (e.g. a preset whose colours later changed): 59 + // keep it verbatim and offer it as a selectable "Custom" option, so editing an unrelated field 60 + // never silently erases the publisher's colours. 61 + const customTheme = 62 + existing?.basicTheme && ! findPresetByColors( existing.basicTheme ) 63 + ? existing.basicTheme 64 + : null; 65 + const [ themeSlug, setThemeSlug ] = useState< string | null >( () => { 66 + const matched = findPresetByColors( existing?.basicTheme )?.slug; 67 + if ( matched ) { 68 + return matched; 69 + } 70 + return customTheme ? CUSTOM_THEME_SLUG : null; 71 + } ); 51 72 const [ uploading, setUploading ] = useState( false ); 52 73 const [ saving, setSaving ] = useState( false ); 53 74 const [ error, setError ] = useState< string | null >( null ); ··· 106 127 name: name.trim(), 107 128 description: description.trim() || undefined, 108 129 icon: icon ?? undefined, 130 + basicTheme: resolveSelectedTheme( themeSlug, customTheme ), 109 131 }; 110 132 const saved = existing 111 133 ? await updatePublication( agent, did, handle, existing, input ) ··· 168 190 disabled={ saving } 169 191 /> 170 192 </label> 193 + 194 + <fieldset className="pubform__themes" role="radiogroup" aria-label="Theme"> 195 + <legend>Theme</legend> 196 + <div className="pubform__theme-grid"> 197 + <label className={ `pubform__theme${ themeSlug === null ? ' is-selected' : '' }` }> 198 + <input 199 + type="radio" 200 + name="pub-theme" 201 + value="" 202 + checked={ themeSlug === null } 203 + onChange={ () => setThemeSlug( null ) } 204 + disabled={ saving } 205 + /> 206 + <span 207 + className="pubform__theme-swatch pubform__theme-swatch--none" 208 + aria-hidden="true" 209 + /> 210 + <span className="pubform__theme-label">No theme</span> 211 + </label> 212 + { customTheme && ( 213 + <label 214 + className={ `pubform__theme${ 215 + themeSlug === CUSTOM_THEME_SLUG ? ' is-selected' : '' 216 + }` } 217 + > 218 + <input 219 + type="radio" 220 + name="pub-theme" 221 + value={ CUSTOM_THEME_SLUG } 222 + checked={ themeSlug === CUSTOM_THEME_SLUG } 223 + onChange={ () => setThemeSlug( CUSTOM_THEME_SLUG ) } 224 + disabled={ saving } 225 + /> 226 + <span 227 + className="pubform__theme-swatch" 228 + style={ { 229 + background: `rgb(${ customTheme.background.r }, ${ customTheme.background.g }, ${ customTheme.background.b })`, 230 + color: `rgb(${ customTheme.foreground.r }, ${ customTheme.foreground.g }, ${ customTheme.foreground.b })`, 231 + } } 232 + aria-hidden="true" 233 + > 234 + <span 235 + className="pubform__theme-dot" 236 + style={ { 237 + background: `rgb(${ customTheme.accent.r }, ${ customTheme.accent.g }, ${ customTheme.accent.b })`, 238 + } } 239 + /> 240 + Aa 241 + </span> 242 + <span className="pubform__theme-label">Current</span> 243 + </label> 244 + ) } 245 + { THEME_PRESETS.map( ( preset ) => { 246 + const swatch = ( color: Rgb ) => `rgb(${ color.r }, ${ color.g }, ${ color.b })`; 247 + return ( 248 + <label 249 + key={ preset.slug } 250 + className={ `pubform__theme${ 251 + themeSlug === preset.slug ? ' is-selected' : '' 252 + }` } 253 + > 254 + <input 255 + type="radio" 256 + name="pub-theme" 257 + value={ preset.slug } 258 + checked={ themeSlug === preset.slug } 259 + onChange={ () => setThemeSlug( preset.slug ) } 260 + disabled={ saving } 261 + /> 262 + <span 263 + className="pubform__theme-swatch" 264 + style={ { 265 + background: swatch( preset.colors.background ), 266 + color: swatch( preset.colors.foreground ), 267 + } } 268 + aria-hidden="true" 269 + > 270 + <span 271 + className="pubform__theme-dot" 272 + style={ { background: swatch( preset.colors.accent ) } } 273 + /> 274 + Aa 275 + </span> 276 + <span className="pubform__theme-label">{ preset.label }</span> 277 + </label> 278 + ); 279 + } ) } 280 + </div> 281 + <small>Sets the colours readers see on your publication.</small> 282 + </fieldset> 171 283 172 284 { isEditing && ( 173 285 <p className="pubform__note">
+51
src/lib/publish/publications.test.ts
··· 7 7 deletePublication, 8 8 } from './publications'; 9 9 import { SITE_BASE } from './records'; 10 + import { THEME_PRESETS } from './themes'; 10 11 import type { BlobRefJson } from '../media/blob'; 11 12 12 13 const DID = 'did:plc:me'; ··· 171 172 await createPublication( agent, DID, 'me.bsky.social', { name: 'Logo Blog', icon } ); 172 173 expect( created[ 0 ].record.icon ).toEqual( icon ); 173 174 } ); 175 + 176 + it( 'writes and returns basicTheme when provided', async () => { 177 + const { agent, created } = mockAgent(); 178 + const pub = await createPublication( agent, DID, 'me.bsky.social', { 179 + name: 'Themed Blog', 180 + basicTheme: THEME_PRESETS[ 4 ].colors, // twilight 181 + } ); 182 + expect( created[ 0 ].record.basicTheme ).toEqual( THEME_PRESETS[ 4 ].colors ); 183 + expect( pub.basicTheme ).toEqual( THEME_PRESETS[ 4 ].colors ); 184 + } ); 185 + 186 + it( 'omits basicTheme when none is chosen', async () => { 187 + const { agent, created } = mockAgent(); 188 + const pub = await createPublication( agent, DID, 'me.bsky.social', { name: 'Plain Blog' } ); 189 + expect( 'basicTheme' in created[ 0 ].record ).toBe( false ); 190 + expect( pub.basicTheme ).toBeUndefined(); 191 + } ); 174 192 } ); 175 193 176 194 describe( 'updatePublication', () => { ··· 193 211 expect( updated.slug ).toBe( 'my-blog' ); 194 212 expect( updated.name ).toBe( 'Renamed Blog' ); 195 213 expect( updated.description ).toBe( 'Now with a description' ); 214 + } ); 215 + 216 + it( 'writes and returns a newly chosen basicTheme', async () => { 217 + const { agent, put } = mockAgent(); 218 + const existing = { 219 + uri: `at://${ DID }/site.standard.publication/a`, 220 + rkey: 'a', 221 + slug: 'my-blog', 222 + name: 'My Blog', 223 + }; 224 + const updated = await updatePublication( agent, DID, 'me.bsky.social', existing, { 225 + name: 'My Blog', 226 + basicTheme: THEME_PRESETS[ 1 ].colors, // noon 227 + } ); 228 + expect( put[ 0 ].record.basicTheme ).toEqual( THEME_PRESETS[ 1 ].colors ); 229 + expect( updated.basicTheme ).toEqual( THEME_PRESETS[ 1 ].colors ); 230 + } ); 231 + } ); 232 + 233 + describe( 'toPublication basicTheme', () => { 234 + it( 'surfaces a valid stored basicTheme and drops a malformed one', async () => { 235 + const good = THEME_PRESETS[ 2 ].colors; // dusk 236 + const { agent } = mockAgent( { 237 + 'site.standard.publication': [ 238 + pubRecord( 'a', `${ SITE_BASE }/@me.bsky.social/a`, { basicTheme: good } ), 239 + pubRecord( 'b', `${ SITE_BASE }/@me.bsky.social/b`, { 240 + basicTheme: { background: { r: 999 } }, 241 + } ), 242 + ], 243 + } ); 244 + const pubs = await listPublications( agent, DID ); 245 + expect( pubs.find( ( p ) => p.slug === 'a' )?.basicTheme ).toEqual( good ); 246 + expect( pubs.find( ( p ) => p.slug === 'b' )?.basicTheme ).toBeUndefined(); 196 247 } ); 197 248 } ); 198 249
+9
src/lib/publish/publications.ts
··· 16 16 type PublicationRecord, 17 17 } from './records'; 18 18 import { normalizeBlobRefJson, type BlobRefJson } from '../media/blob'; 19 + import { parseBasicTheme, type BasicTheme } from './themes'; 19 20 20 21 const PUBLICATION_COLLECTION = 'site.standard.publication'; 21 22 const DOCUMENT_COLLECTION = 'site.standard.document'; ··· 39 40 name: string; 40 41 description?: string; 41 42 icon?: BlobRefJson; 43 + basicTheme?: BasicTheme; 42 44 } 43 45 44 46 /** Fields a writer can set/change on a publication (the slug is derived, never entered). */ ··· 46 48 name: string; 47 49 description?: string; 48 50 icon?: BlobRefJson; 51 + basicTheme?: BasicTheme; 49 52 } 50 53 51 54 /** Map a raw repo record to a Publication, or null if it isn't a usable SkyPress publication. */ ··· 64 67 // The agent deserialises the icon blob into a BlobRef (ref = CID object); normalise back to 65 68 // the portable `{ ref: { $link } }` shape the dashboard + form read from. 66 69 const icon = normalizeBlobRefJson( value.icon ); 70 + const basicTheme = parseBasicTheme( value.basicTheme ); 67 71 return { 68 72 uri: record.uri, 69 73 rkey: rkeyFromUri( record.uri ), ··· 71 75 name: value.name ?? slug, 72 76 ...( value.description ? { description: value.description } : {} ), 73 77 ...( icon ? { icon } : {} ), 78 + ...( basicTheme ? { basicTheme } : {} ), 74 79 }; 75 80 } 76 81 ··· 110 115 name: input.name, 111 116 description: input.description, 112 117 icon: input.icon, 118 + basicTheme: input.basicTheme, 113 119 } ); 114 120 const res = await agent.com.atproto.repo.createRecord( { 115 121 repo: did, ··· 124 130 name: record.name, 125 131 ...( record.description ? { description: record.description } : {} ), 126 132 ...( record.icon ? { icon: record.icon } : {} ), 133 + ...( record.basicTheme ? { basicTheme: record.basicTheme } : {} ), 127 134 }; 128 135 } 129 136 ··· 144 151 name: input.name, 145 152 description: input.description, 146 153 icon: input.icon, 154 + basicTheme: input.basicTheme, 147 155 } ); 148 156 await agent.com.atproto.repo.putRecord( { 149 157 repo: did, ··· 158 166 name: record.name, 159 167 ...( record.description ? { description: record.description } : {} ), 160 168 ...( record.icon ? { icon: record.icon } : {} ), 169 + ...( record.basicTheme ? { basicTheme: record.basicTheme } : {} ), 161 170 }; 162 171 } 163 172
+31
src/lib/publish/records.test.ts
··· 18 18 } from './records'; 19 19 import type { BlockNode } from '../blocks/render'; 20 20 import type { BlobRefJson } from '../media/blob'; 21 + import { THEME_PRESETS } from './themes'; 21 22 22 23 const BLOCKS: BlockNode[] = [ 23 24 { name: 'core/heading', attributes: { level: 1, content: 'Hi' }, innerBlocks: [] }, ··· 158 159 } ); 159 160 expect( full.description ).toBe( 'A blog' ); 160 161 expect( full.icon ).toEqual( icon ); 162 + } ); 163 + 164 + it( 'includes basicTheme when provided and omits it otherwise', () => { 165 + const themed = buildPublicationRecord( { 166 + handle: 'a.b', 167 + slug: 's', 168 + name: 'N', 169 + basicTheme: THEME_PRESETS[ 0 ].colors, 170 + } ); 171 + expect( themed.basicTheme ).toEqual( THEME_PRESETS[ 0 ].colors ); 172 + 173 + const bare = buildPublicationRecord( { handle: 'a.b', slug: 's', name: 'N' } ); 174 + expect( 'basicTheme' in bare ).toBe( false ); 175 + } ); 176 + 177 + it( 'drops an invalid basicTheme rather than persisting it (write-boundary validation)', () => { 178 + const record = buildPublicationRecord( { 179 + handle: 'a.b', 180 + slug: 's', 181 + name: 'N', 182 + // Out-of-range channel — must never reach storage. 183 + basicTheme: { 184 + $type: 'site.standard.theme.basic', 185 + background: { r: 300, g: 0, b: 0 }, 186 + foreground: { r: 0, g: 0, b: 0 }, 187 + accent: { r: 0, g: 0, b: 0 }, 188 + accentForeground: { r: 0, g: 0, b: 0 }, 189 + } as never, 190 + } ); 191 + expect( 'basicTheme' in record ).toBe( false ); 161 192 } ); 162 193 } ); 163 194
+8
src/lib/publish/records.ts
··· 6 6 */ 7 7 import type { BlockNode } from '../blocks/render'; 8 8 import type { BlobRefJson } from '../media/blob'; 9 + import { parseBasicTheme, type BasicTheme } from './themes'; 9 10 10 11 /** 11 12 * Public origin for the stored publication + article URLs (and the Bluesky post link). ··· 148 149 description?: string; 149 150 /** The publication logo (≤1MB, image/*) — the lexicon's `icon` blob (Decision 0010). */ 150 151 icon?: BlobRefJson; 152 + /** Optional sky-phase colour theme — the lexicon's `basicTheme` object (Decision 0012). */ 153 + basicTheme?: BasicTheme; 151 154 } 152 155 153 156 export function buildPublicationRecord( input: { ··· 157 160 name?: string; 158 161 description?: string; 159 162 icon?: BlobRefJson; 163 + basicTheme?: BasicTheme; 160 164 } ): PublicationRecord { 161 165 const trimmedName = input.name?.trim(); 162 166 const description = input.description?.trim(); 167 + // Validate the theme at the write boundary too (symmetric with the read path): an invalid 168 + // theme is dropped rather than persisted, so no out-of-range channel can ever reach storage. 169 + const basicTheme = parseBasicTheme( input.basicTheme ); 163 170 return { 164 171 $type: 'site.standard.publication', 165 172 url: publicationHomeUrl( input.handle, input.slug ), 166 173 name: trimmedName || input.handle, 167 174 ...( description ? { description } : {} ), 168 175 ...( input.icon ? { icon: input.icon } : {} ), 176 + ...( basicTheme ? { basicTheme } : {} ), 169 177 }; 170 178 } 171 179
+206
src/lib/publish/themes.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + THEME_PRESETS, 4 + parseBasicTheme, 5 + findPresetByColors, 6 + themeToCssVars, 7 + themeStyleBlock, 8 + resolveSelectedTheme, 9 + CUSTOM_THEME_SLUG, 10 + type BasicTheme, 11 + } from './themes'; 12 + 13 + const luminance = ( { r, g, b }: { r: number; g: number; b: number } ) => { 14 + const ch = ( c: number ) => { 15 + const s = c / 255; 16 + return s <= 0.03928 ? s / 12.92 : ( ( s + 0.055 ) / 1.055 ) ** 2.4; 17 + }; 18 + return 0.2126 * ch( r ) + 0.7152 * ch( g ) + 0.0722 * ch( b ); 19 + }; 20 + const ratio = ( a: BasicTheme[ 'background' ], b: BasicTheme[ 'background' ] ) => { 21 + const la = luminance( a ) + 0.05; 22 + const lb = luminance( b ) + 0.05; 23 + return la > lb ? la / lb : lb / la; 24 + }; 25 + 26 + describe( 'THEME_PRESETS', () => { 27 + it( 'ships the 8 sky phases with unique slugs', () => { 28 + expect( THEME_PRESETS ).toHaveLength( 8 ); 29 + expect( THEME_PRESETS.map( ( p ) => p.slug ) ).toEqual( [ 30 + 'evening', 31 + 'noon', 32 + 'dusk', 33 + 'afternoon', 34 + 'twilight', 35 + 'morning', 36 + 'sunrise', 37 + 'midnight', 38 + ] ); 39 + expect( new Set( THEME_PRESETS.map( ( p ) => p.slug ) ).size ).toBe( 8 ); 40 + } ); 41 + 42 + it( 'every preset clears WCAG AA for body text and button text', () => { 43 + for ( const preset of THEME_PRESETS ) { 44 + const { background, foreground, accent, accentForeground } = preset.colors; 45 + expect( ratio( background, foreground ) ).toBeGreaterThanOrEqual( 4.5 ); 46 + expect( ratio( accent, accentForeground ) ).toBeGreaterThanOrEqual( 4.5 ); 47 + } 48 + } ); 49 + 50 + it( 'every preset color channel is an integer 0–255', () => { 51 + for ( const preset of THEME_PRESETS ) { 52 + for ( const color of [ 53 + preset.colors.background, 54 + preset.colors.foreground, 55 + preset.colors.accent, 56 + preset.colors.accentForeground, 57 + ] ) { 58 + for ( const channel of [ color.r, color.g, color.b ] ) { 59 + expect( Number.isInteger( channel ) ).toBe( true ); 60 + expect( channel ).toBeGreaterThanOrEqual( 0 ); 61 + expect( channel ).toBeLessThanOrEqual( 255 ); 62 + } 63 + } 64 + } 65 + } ); 66 + } ); 67 + 68 + describe( 'parseBasicTheme', () => { 69 + it( 'accepts a valid theme and stamps $type', () => { 70 + const parsed = parseBasicTheme( THEME_PRESETS[ 0 ].colors ); 71 + expect( parsed ).not.toBeNull(); 72 + expect( parsed!.$type ).toBe( 'site.standard.theme.basic' ); 73 + expect( parsed!.accent ).toEqual( THEME_PRESETS[ 0 ].colors.accent ); 74 + } ); 75 + 76 + it( 'rejects undefined, non-objects, and out-of-range / non-integer channels', () => { 77 + expect( parseBasicTheme( undefined ) ).toBeNull(); 78 + expect( parseBasicTheme( 'nope' ) ).toBeNull(); 79 + expect( parseBasicTheme( { background: { r: 0, g: 0, b: 0 } } ) ).toBeNull(); 80 + expect( 81 + parseBasicTheme( { 82 + background: { r: 300, g: 0, b: 0 }, 83 + foreground: { r: 0, g: 0, b: 0 }, 84 + accent: { r: 0, g: 0, b: 0 }, 85 + accentForeground: { r: 0, g: 0, b: 0 }, 86 + } ) 87 + ).toBeNull(); 88 + expect( 89 + parseBasicTheme( { 90 + background: { r: 1.5, g: 0, b: 0 }, 91 + foreground: { r: 0, g: 0, b: 0 }, 92 + accent: { r: 0, g: 0, b: 0 }, 93 + accentForeground: { r: 0, g: 0, b: 0 }, 94 + } ) 95 + ).toBeNull(); 96 + } ); 97 + } ); 98 + 99 + describe( 'findPresetByColors', () => { 100 + it( 'reverse-matches stored colours to a preset', () => { 101 + const preset = findPresetByColors( THEME_PRESETS[ 3 ].colors ); 102 + expect( preset?.slug ).toBe( 'afternoon' ); 103 + } ); 104 + 105 + it( 'returns null for none/custom colours', () => { 106 + expect( findPresetByColors( null ) ).toBeNull(); 107 + expect( findPresetByColors( undefined ) ).toBeNull(); 108 + expect( 109 + findPresetByColors( { 110 + $type: 'site.standard.theme.basic', 111 + background: { r: 1, g: 2, b: 3 }, 112 + foreground: { r: 4, g: 5, b: 6 }, 113 + accent: { r: 7, g: 8, b: 9 }, 114 + accentForeground: { r: 10, g: 11, b: 12 }, 115 + } ) 116 + ).toBeNull(); 117 + } ); 118 + 119 + it( 'round-trips a parsed theme back to its preset (the edit-prefill path)', () => { 120 + // Production prefill reads `existing.basicTheme`, which came through parseBasicTheme. 121 + const parsed = parseBasicTheme( THEME_PRESETS[ 6 ].colors ); // sunrise 122 + expect( findPresetByColors( parsed )?.slug ).toBe( 'sunrise' ); 123 + } ); 124 + } ); 125 + 126 + describe( 'resolveSelectedTheme', () => { 127 + const custom: BasicTheme = { 128 + $type: 'site.standard.theme.basic', 129 + background: { r: 1, g: 2, b: 3 }, 130 + foreground: { r: 4, g: 5, b: 6 }, 131 + accent: { r: 7, g: 8, b: 9 }, 132 + accentForeground: { r: 10, g: 11, b: 12 }, 133 + }; 134 + 135 + it( 'maps null to undefined (no theme)', () => { 136 + expect( resolveSelectedTheme( null, null ) ).toBeUndefined(); 137 + expect( resolveSelectedTheme( null, custom ) ).toBeUndefined(); 138 + } ); 139 + 140 + it( 'maps a preset slug to that preset’s colours', () => { 141 + expect( resolveSelectedTheme( 'dusk', null ) ).toEqual( THEME_PRESETS[ 2 ].colors ); 142 + } ); 143 + 144 + it( 'keeps the custom theme verbatim for the custom sentinel', () => { 145 + expect( resolveSelectedTheme( CUSTOM_THEME_SLUG, custom ) ).toEqual( custom ); 146 + // No custom theme to keep → no theme rather than a dangling sentinel. 147 + expect( resolveSelectedTheme( CUSTOM_THEME_SLUG, null ) ).toBeUndefined(); 148 + } ); 149 + 150 + it( 'maps an unknown slug to undefined (preset removed) rather than crashing', () => { 151 + expect( resolveSelectedTheme( 'no-such-preset', null ) ).toBeUndefined(); 152 + } ); 153 + } ); 154 + 155 + describe( 'themeToCssVars', () => { 156 + const vars = themeToCssVars( THEME_PRESETS[ 0 ].colors ); // evening 157 + 158 + it( 'maps the core tokens to rgb() strings', () => { 159 + expect( vars[ '--paper' ] ).toBe( 'rgb(27, 27, 27)' ); 160 + expect( vars[ '--ink' ] ).toBe( 'rgb(240, 240, 240)' ); 161 + expect( vars[ '--sun' ] ).toBe( 'rgb(68, 35, 105)' ); 162 + expect( vars[ '--btn-primary' ] ).toBe( 'rgb(68, 35, 105)' ); 163 + expect( vars[ '--btn-primary-fg' ] ).toBe( 'rgb(255, 255, 255)' ); 164 + } ); 165 + 166 + it( 'derives intermediate tokens as in-range rgb() strings', () => { 167 + for ( const key of [ '--muted', '--line', '--line-strong', '--sun-tint', '--btn-primary-hover' ] ) { 168 + expect( vars[ key ] ).toMatch( /^rgb\(\d{1,3}, \d{1,3}, \d{1,3}\)$/ ); 169 + } 170 + } ); 171 + 172 + it( 'blends muted as foreground OVER background (direction locked)', () => { 173 + // evening: bg 27, fg 240 → mix(fg, bg, 0.55) = round(240*.55 + 27*.45) = round(144.15) = 144. 174 + // If the blend direction inverted (bg over fg) this would be ~123 — caught here. 175 + expect( vars[ '--muted' ] ).toBe( 'rgb(144, 144, 144)' ); 176 + } ); 177 + 178 + it( 'carries accentForeground into --btn-primary-fg', () => { 179 + const noon = themeToCssVars( THEME_PRESETS[ 1 ].colors ); // accentForeground = black 180 + expect( noon[ '--btn-primary-fg' ] ).toBe( 'rgb(0, 0, 0)' ); 181 + } ); 182 + } ); 183 + 184 + describe( 'themeStyleBlock', () => { 185 + it( 'returns a :root override style tag for a theme', () => { 186 + const html = themeStyleBlock( THEME_PRESETS[ 1 ].colors ); // noon 187 + expect( html ).toContain( '<style>' ); 188 + expect( html ).toContain( ':root' ); 189 + expect( html ).toContain( '--paper: rgb(248, 247, 245)' ); 190 + expect( html ).toContain( '.btn--primary' ); 191 + } ); 192 + 193 + it( 'returns an empty string when there is no theme', () => { 194 + expect( themeStyleBlock( null ) ).toBe( '' ); 195 + expect( themeStyleBlock( undefined ) ).toBe( '' ); 196 + } ); 197 + 198 + it( 'never emits anything but digits/commas inside rgb() (no injection surface)', () => { 199 + const html = themeStyleBlock( THEME_PRESETS[ 2 ].colors ); 200 + const matches = html.match( /rgb\([^)]*\)/g ) ?? []; 201 + expect( matches.length ).toBeGreaterThan( 0 ); 202 + for ( const m of matches ) { 203 + expect( m ).toMatch( /^rgb\(\d{1,3}, \d{1,3}, \d{1,3}\)$/ ); 204 + } 205 + } ); 206 + } );
+196
src/lib/publish/themes.ts
··· 1 + /** 2 + * Publication colour themes ("sky phases"). Pure + dependency-free (no `@atproto`, 3 + * no `@wordpress`, no DOM) so the SSR reader can import it. Presets are derived from 4 + * the Twenty Twenty-Five colour style variations; each accent/accentForeground pair 5 + * is chosen to clear WCAG AA. Stored on `site.standard.publication.basicTheme` 6 + * (lexicon `site.standard.theme.basic`) — the standard field, so other readers honour it. 7 + * (Decision 0012.) 8 + */ 9 + 10 + export interface Rgb { 11 + r: number; 12 + g: number; 13 + b: number; 14 + } 15 + 16 + export interface BasicTheme { 17 + $type: 'site.standard.theme.basic'; 18 + /** content background */ 19 + background: Rgb; 20 + /** content text */ 21 + foreground: Rgb; 22 + /** links + button backgrounds */ 23 + accent: Rgb; 24 + /** button text */ 25 + accentForeground: Rgb; 26 + } 27 + 28 + export interface ThemePreset { 29 + slug: string; 30 + label: string; 31 + colors: BasicTheme; 32 + } 33 + 34 + const rgb = ( r: number, g: number, b: number ): Rgb => ( { r, g, b } ); 35 + 36 + const preset = ( 37 + slug: string, 38 + label: string, 39 + background: Rgb, 40 + foreground: Rgb, 41 + accent: Rgb, 42 + accentForeground: Rgb 43 + ): ThemePreset => ( { 44 + slug, 45 + label, 46 + colors: { $type: 'site.standard.theme.basic', background, foreground, accent, accentForeground }, 47 + } ); 48 + 49 + /** 50 + * The eight sky-phase presets (Twenty Twenty-Five colour palettes). Mapping per palette: 51 + * `background ← base`, `foreground ← contrast`, `accent ←` the palette's signature accent, 52 + * `accentForeground ←` whichever of black/white clears WCAG AA on that accent. 53 + */ 54 + export const THEME_PRESETS: readonly ThemePreset[] = [ 55 + preset( 'evening', 'Evening', rgb( 27, 27, 27 ), rgb( 240, 240, 240 ), rgb( 68, 35, 105 ), rgb( 255, 255, 255 ) ), 56 + preset( 'noon', 'Noon', rgb( 248, 247, 245 ), rgb( 25, 25, 25 ), rgb( 245, 182, 132 ), rgb( 0, 0, 0 ) ), 57 + preset( 'dusk', 'Dusk', rgb( 226, 226, 226 ), rgb( 59, 59, 59 ), rgb( 101, 13, 212 ), rgb( 255, 255, 255 ) ), 58 + preset( 'afternoon', 'Afternoon', rgb( 218, 231, 189 ), rgb( 81, 96, 40 ), rgb( 199, 246, 66 ), rgb( 0, 0, 0 ) ), 59 + preset( 'twilight', 'Twilight', rgb( 19, 19, 19 ), rgb( 255, 255, 255 ), rgb( 75, 82, 255 ), rgb( 255, 255, 255 ) ), 60 + preset( 'morning', 'Morning', rgb( 223, 220, 215 ), rgb( 25, 25, 25 ), rgb( 122, 155, 219 ), rgb( 0, 0, 0 ) ), 61 + preset( 'sunrise', 'Sunrise', rgb( 51, 6, 22 ), rgb( 255, 255, 255 ), rgb( 219, 154, 177 ), rgb( 0, 0, 0 ) ), 62 + preset( 'midnight', 'Midnight', rgb( 68, 51, 166 ), rgb( 121, 243, 177 ), rgb( 232, 183, 255 ), rgb( 0, 0, 0 ) ), 63 + ]; 64 + 65 + const isChannel = ( v: unknown ): v is number => 66 + typeof v === 'number' && Number.isInteger( v ) && v >= 0 && v <= 255; 67 + 68 + const isRgb = ( v: unknown ): v is Rgb => { 69 + if ( ! v || typeof v !== 'object' ) { 70 + return false; 71 + } 72 + const c = v as Record< string, unknown >; 73 + return isChannel( c.r ) && isChannel( c.g ) && isChannel( c.b ); 74 + }; 75 + 76 + /** 77 + * Validate a PDS-sourced value into a `BasicTheme`, or null. Untrusted input: every 78 + * channel must be an integer 0–255 (the renderer relies on this to emit safe `rgb()`). 79 + */ 80 + export function parseBasicTheme( value: unknown ): BasicTheme | null { 81 + if ( ! value || typeof value !== 'object' ) { 82 + return null; 83 + } 84 + const v = value as Record< string, unknown >; 85 + if ( 86 + ! isRgb( v.background ) || 87 + ! isRgb( v.foreground ) || 88 + ! isRgb( v.accent ) || 89 + ! isRgb( v.accentForeground ) 90 + ) { 91 + return null; 92 + } 93 + return { 94 + $type: 'site.standard.theme.basic', 95 + background: { r: v.background.r, g: v.background.g, b: v.background.b }, 96 + foreground: { r: v.foreground.r, g: v.foreground.g, b: v.foreground.b }, 97 + accent: { r: v.accent.r, g: v.accent.g, b: v.accent.b }, 98 + accentForeground: { r: v.accentForeground.r, g: v.accentForeground.g, b: v.accentForeground.b }, 99 + }; 100 + } 101 + 102 + /** 103 + * Sentinel slug for "keep the publication's current colours" — used when a stored theme doesn't 104 + * match any preset (e.g. a future preset whose colours changed). Keeps the picker from silently 105 + * erasing a theme it can't name. Not a real preset; never written as a slug. 106 + */ 107 + export const CUSTOM_THEME_SLUG = 'custom'; 108 + 109 + /** 110 + * Resolve the picker's selection to the colours to store. `null` slug → no theme (omit); 111 + * the custom sentinel → keep `customTheme` verbatim; any other slug → that preset's colours. 112 + * Pure, so the form's submit mapping is unit-testable without rendering. 113 + */ 114 + export function resolveSelectedTheme( 115 + slug: string | null, 116 + customTheme: BasicTheme | null | undefined 117 + ): BasicTheme | undefined { 118 + if ( slug === null ) { 119 + return undefined; 120 + } 121 + if ( slug === CUSTOM_THEME_SLUG ) { 122 + return customTheme ?? undefined; 123 + } 124 + return THEME_PRESETS.find( ( preset ) => preset.slug === slug )?.colors; 125 + } 126 + 127 + const sameRgb = ( a: Rgb, b: Rgb ): boolean => a.r === b.r && a.g === b.g && a.b === b.b; 128 + 129 + /** Reverse-match stored colours to a known preset (so the picker highlights it), or null. */ 130 + export function findPresetByColors( theme: BasicTheme | null | undefined ): ThemePreset | null { 131 + if ( ! theme ) { 132 + return null; 133 + } 134 + return ( 135 + THEME_PRESETS.find( 136 + ( p ) => 137 + sameRgb( p.colors.background, theme.background ) && 138 + sameRgb( p.colors.foreground, theme.foreground ) && 139 + sameRgb( p.colors.accent, theme.accent ) && 140 + sameRgb( p.colors.accentForeground, theme.accentForeground ) 141 + ) ?? null 142 + ); 143 + } 144 + 145 + const clamp255 = ( n: number ): number => Math.max( 0, Math.min( 255, Math.round( n ) ) ); 146 + 147 + /** Linear per-channel blend: `t` of `a` over `(1 - t)` of `b`. */ 148 + const mix = ( a: Rgb, b: Rgb, t: number ): Rgb => ( { 149 + r: clamp255( a.r * t + b.r * ( 1 - t ) ), 150 + g: clamp255( a.g * t + b.g * ( 1 - t ) ), 151 + b: clamp255( a.b * t + b.b * ( 1 - t ) ), 152 + } ); 153 + 154 + const css = ( { r, g, b }: Rgb ): string => `rgb(${ r }, ${ g }, ${ b })`; 155 + 156 + /** 157 + * Map a `BasicTheme` to the SkyPress design tokens it overrides. Background/foreground/accent 158 + * families map directly; borders + muted text are deterministic blends of fg over bg so the 159 + * editorial hierarchy survives any palette. A publisher's palette is a fixed identity, so this 160 + * intentionally overrides both the light and dark `:root` defaults. 161 + */ 162 + export function themeToCssVars( theme: BasicTheme ): Record< string, string > { 163 + const { background: bg, foreground: fg, accent, accentForeground } = theme; 164 + return { 165 + '--paper': css( bg ), 166 + '--paper-raised': css( mix( fg, bg, 0.04 ) ), 167 + '--panel': css( mix( fg, bg, 0.06 ) ), 168 + '--ink': css( fg ), 169 + '--ink-soft': css( mix( fg, bg, 0.8 ) ), 170 + '--muted': css( mix( fg, bg, 0.55 ) ), 171 + '--line': css( mix( fg, bg, 0.14 ) ), 172 + '--line-strong': css( mix( fg, bg, 0.26 ) ), 173 + '--sun': css( accent ), 174 + '--sun-strong': css( mix( accent, fg, 0.82 ) ), 175 + '--sun-tint': css( mix( accent, bg, 0.2 ) ), 176 + '--btn-primary': css( accent ), 177 + '--btn-primary-hover': css( mix( accent, fg, 0.85 ) ), 178 + '--btn-primary-fg': css( accentForeground ), 179 + '--ember': css( accent ), 180 + }; 181 + } 182 + 183 + /** 184 + * A `<style>` block overriding the design tokens for a themed publication, or '' when there is 185 + * no theme. Also re-points `.btn--primary` text at `--btn-primary-fg` so button text follows the 186 + * theme's accentForeground. All values are app-built `rgb()` strings — no record text reaches CSS. 187 + */ 188 + export function themeStyleBlock( theme: BasicTheme | null | undefined ): string { 189 + if ( ! theme ) { 190 + return ''; 191 + } 192 + const decls = Object.entries( themeToCssVars( theme ) ) 193 + .map( ( [ key, value ] ) => `${ key }: ${ value };` ) 194 + .join( ' ' ); 195 + return `<style>:root { ${ decls } } .btn--primary, .btn--primary:hover { color: var(--btn-primary-fg); }</style>`; 196 + }
+22
src/lib/reader/publications.test.ts
··· 2 2 import { listReaderPublications, resolveReaderPublication } from './publications'; 3 3 import { listRecords } from './records'; 4 4 import { SITE_BASE } from '../publish/records'; 5 + import { THEME_PRESETS } from '../publish/themes'; 5 6 6 7 vi.mock( './records', () => ( { listRecords: vi.fn() } ) ); 7 8 const mockedList = listRecords as unknown as ReturnType< typeof vi.fn >; ··· 48 49 expect( await resolveReaderPublication( PDS, DID, 'missing' ) ).toBeNull(); 49 50 } ); 50 51 } ); 52 + 53 + describe( 'reader basicTheme', () => { 54 + it( 'surfaces a valid stored basicTheme', async () => { 55 + mockedList.mockResolvedValue( [ 56 + rec( 'a', `${ SITE_BASE }/@me.bsky.social/blog`, { 57 + basicTheme: THEME_PRESETS[ 5 ].colors, // morning 58 + } ), 59 + ] ); 60 + const pub = await resolveReaderPublication( PDS, DID, 'blog' ); 61 + expect( pub?.basicTheme ).toEqual( THEME_PRESETS[ 5 ].colors ); 62 + } ); 63 + 64 + it( 'sets basicTheme to null when absent or malformed', async () => { 65 + mockedList.mockResolvedValue( [ 66 + rec( 'a', `${ SITE_BASE }/@me.bsky.social/plain` ), 67 + rec( 'b', `${ SITE_BASE }/@me.bsky.social/broken`, { basicTheme: { accent: 'red' } } ), 68 + ] ); 69 + expect( ( await resolveReaderPublication( PDS, DID, 'plain' ) )?.basicTheme ).toBeNull(); 70 + expect( ( await resolveReaderPublication( PDS, DID, 'broken' ) )?.basicTheme ).toBeNull(); 71 + } ); 72 + } );
+4
src/lib/reader/publications.ts
··· 12 12 publicationSlugFromUrl, 13 13 } from '../publish/records'; 14 14 import type { BlobRefJson } from '../media/blob'; 15 + import { parseBasicTheme, type BasicTheme } from '../publish/themes'; 15 16 16 17 export interface ReaderPublication { 17 18 uri: string; ··· 19 20 name: string; 20 21 description: string | null; 21 22 icon: BlobRefJson | null; 23 + basicTheme: BasicTheme | null; 22 24 } 23 25 24 26 interface RawPublication { ··· 26 28 name?: string; 27 29 description?: string; 28 30 icon?: BlobRefJson; 31 + basicTheme?: unknown; 29 32 } 30 33 31 34 function toReaderPublication( record: { uri: string; value: RawPublication } ): ReaderPublication | null { ··· 43 46 name: value.name ?? slug, 44 47 description: value.description?.trim() || null, 45 48 icon: value.icon ?? null, 49 + basicTheme: parseBasicTheme( value.basicTheme ), 46 50 }; 47 51 } 48 52
+47
src/lib/reader/theme-injection.test.ts
··· 1 + /** 2 + * Regression guard for the publication-theme injection (Decision 0012). 3 + * 4 + * The publication-home and article pages inject a `<style>` overriding the design tokens when a 5 + * publication has a `basicTheme`. Rendering these `.astro` pages through astro/container isn't 6 + * viable here (the runner is pinned to jsdom for the WordPress block suites, which breaks 7 + * esbuild's init invariant — see index.phase.test.ts), so these asserts pin the wiring at the 8 + * source level: each page must compute `themeStyleBlock(publication.basicTheme)` and inject it, 9 + * gated on a truthy result, via `set:html` (the value is app-built CSS, proven injection-safe by 10 + * themes.test.ts). A refactor that drops the gate or passes the wrong field ships green without it. 11 + * 12 + * Lives under src/lib/ (not src/pages/) on purpose: a `.test.ts` inside a `[param]` route dir is 13 + * treated by Astro as a prerendered endpoint and breaks the build. 14 + */ 15 + import { readFileSync } from 'node:fs'; 16 + import { fileURLToPath } from 'node:url'; 17 + import { describe, expect, it } from 'vitest'; 18 + 19 + const read = ( rel: string ) => 20 + readFileSync( fileURLToPath( new URL( rel, import.meta.url ) ), 'utf8' ); 21 + 22 + describe( 'publication theme injection wiring', () => { 23 + for ( const [ label, file ] of [ 24 + [ 'publication home', '../../pages/[author]/[slug]/index.astro' ], 25 + [ 'article', '../../pages/[author]/[slug]/[rkey].astro' ], 26 + ] as const ) { 27 + describe( label, () => { 28 + const src = read( file ); 29 + 30 + it( 'imports themeStyleBlock from the themes module', () => { 31 + expect( src ).toMatch( 32 + /import\s*\{\s*themeStyleBlock\s*\}\s*from\s*['"][^'"]*publish\/themes['"]/ 33 + ); 34 + } ); 35 + 36 + it( 'derives the style block from the publication’s basicTheme', () => { 37 + expect( src ).toMatch( /themeStyleBlock\(\s*publication\.basicTheme\s*\)/ ); 38 + } ); 39 + 40 + it( 'injects it via set:html, gated on a truthy theme', () => { 41 + expect( src ).toMatch( 42 + /\{\s*themeStyle\s*&&\s*<Fragment\s+set:html=\{\s*themeStyle\s*\}\s*\/>\s*\}/ 43 + ); 44 + } ); 45 + } ); 46 + } 47 + } );
+3
src/pages/[author]/[slug]/[rkey].astro
··· 8 8 import { renderBlocks, blocksToText, type BlockNode } from '../../../lib/blocks/render'; 9 9 import { sanitizeArticleHtml } from '../../../lib/reader/sanitize'; 10 10 import { canonicalArticleUrl } from '../../../lib/publish/records'; 11 + import { themeStyleBlock } from '../../../lib/publish/themes'; 11 12 12 13 // Frontend block styles only — no editor chrome, no JS. 13 14 import '@wordpress/block-library/build-style/common.css'; ··· 72 73 const readingMinutes = Math.max( 1, Math.round( words / 200 ) ); 73 74 const publishedLabel = doc.publishedAt ? doc.publishedAt.slice( 0, 10 ) : null; 74 75 const updatedLabel = doc.updatedAt ? doc.updatedAt.slice( 0, 10 ) : null; 76 + const themeStyle = themeStyleBlock( publication.basicTheme ); 75 77 --- 76 78 77 79 <Base title={`${ title } — ${ publication.name }`} description={description}> ··· 85 87 <meta property="og:title" content={title} /> 86 88 <meta property="og:description" content={description} /> 87 89 <meta property="og:url" content={canonical} /> 90 + {themeStyle && <Fragment set:html={themeStyle} />} 88 91 </Fragment> 89 92 90 93 <header class="masthead">
+3
src/pages/[author]/[slug]/index.astro
··· 6 6 import { resolveReaderPublication } from '../../../lib/reader/publications'; 7 7 import { fetchActorProfile } from '../../../lib/reader/profile'; 8 8 import { buildGetBlobUrl } from '../../../lib/media/blob'; 9 + import { themeStyleBlock } from '../../../lib/publish/themes'; 9 10 10 11 export const prerender = false; 11 12 ··· 56 57 const authorName = profile.displayName ?? `@${ handle }`; 57 58 const initial = publication.name.charAt( 0 ).toUpperCase(); 58 59 const feedHref = `/${ author }/${ slug }/rss.xml`; 60 + const themeStyle = themeStyleBlock( publication.basicTheme ); 59 61 --- 60 62 61 63 <Base title={`${ publication.name } — SkyPress`} description={publication.description ?? undefined}> 62 64 <Fragment slot="head"> 63 65 <link rel="site.standard.publication" href={publication.uri} /> 64 66 <link rel="alternate" type="application/rss+xml" title={`${ publication.name } — RSS`} href={feedHref} /> 67 + {themeStyle && <Fragment set:html={themeStyle} />} 65 68 </Fragment> 66 69 67 70 <header class="masthead">
+76
src/pages/dashboard.astro
··· 351 351 color: var(--ember); 352 352 font-size: 0.9rem; 353 353 } 354 + .pubform__themes { 355 + border: 0; 356 + margin: 0 0 1rem; 357 + padding: 0; 358 + min-width: 0; 359 + } 360 + .pubform__themes legend { 361 + font-size: 0.85rem; 362 + font-weight: 600; 363 + padding: 0; 364 + margin-bottom: 0.4rem; 365 + } 366 + .pubform__themes small { 367 + display: block; 368 + margin-top: 0.5rem; 369 + font-size: 0.82rem; 370 + color: var(--muted); 371 + } 372 + .pubform__theme-grid { 373 + display: grid; 374 + grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); 375 + gap: 0.6rem; 376 + } 377 + .pubform__theme { 378 + display: flex; 379 + flex-direction: column; 380 + align-items: center; 381 + gap: 0.35rem; 382 + cursor: pointer; 383 + } 384 + .pubform__theme input { 385 + position: absolute; 386 + width: 1px; 387 + height: 1px; 388 + opacity: 0; 389 + pointer-events: none; 390 + } 391 + .pubform__theme-swatch { 392 + display: flex; 393 + align-items: center; 394 + justify-content: center; 395 + gap: 0.3rem; 396 + width: 100%; 397 + aspect-ratio: 16 / 10; 398 + border-radius: var(--radius-sm); 399 + border: 2px solid var(--line-strong); 400 + font-family: var(--font-display); 401 + font-weight: 600; 402 + font-size: 0.95rem; 403 + } 404 + .pubform__theme.is-selected .pubform__theme-swatch { 405 + border-color: var(--sun); 406 + box-shadow: 0 0 0 2px var(--sun); 407 + } 408 + .pubform__theme input:focus-visible + .pubform__theme-swatch { 409 + outline: 2px solid var(--sun); 410 + outline-offset: 2px; 411 + } 412 + .pubform__theme-swatch--none { 413 + background: repeating-linear-gradient( 414 + 45deg, 415 + var(--panel), 416 + var(--panel) 6px, 417 + var(--paper-raised) 6px, 418 + var(--paper-raised) 12px 419 + ); 420 + } 421 + .pubform__theme-dot { 422 + width: 0.7rem; 423 + height: 0.7rem; 424 + border-radius: 50%; 425 + } 426 + .pubform__theme-label { 427 + font-size: 0.8rem; 428 + color: var(--ink-soft); 429 + } 354 430 .pubform__actions { 355 431 display: flex; 356 432 gap: 0.75rem;
+4 -2
src/styles/global.css
··· 39 39 white text. Bare --sun is too light for white text (~2.5:1). */ 40 40 --btn-primary: #b85c12; 41 41 --btn-primary-hover: #9a4c0f; 42 + --btn-primary-fg: #fff; 42 43 --ember: #bb5a36; /* rare warm accent (the ink/press counterweight) */ 43 44 44 45 --radius: 12px; ··· 63 64 /* Dark mode's accents are light, so white text still needs a deeper fill. */ 64 65 --btn-primary: #aa6010; 65 66 --btn-primary-hover: #964f0c; 67 + --btn-primary-fg: #fff; 66 68 --ember: #e08a63; 67 69 --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 18px 40px -16px rgba(0, 0, 0, 0.6); 68 70 } ··· 142 144 } 143 145 .btn--primary { 144 146 background: var(--btn-primary); 145 - color: #fff; 147 + color: var(--btn-primary-fg); 146 148 } 147 149 .btn--primary:hover { 148 150 background: var(--btn-primary-hover); 149 - color: #fff; 151 + color: var(--btn-primary-fg); 150 152 } 151 153 .btn--ghost { 152 154 background: var(--paper-raised);
+1 -1
vitest.config.ts
··· 5 5 globals: true, 6 6 // jsdom: @wordpress/block-library block registration touches browser globals. 7 7 environment: 'jsdom', 8 - include: [ 'src/**/*.test.ts' ], 8 + include: [ 'src/**/*.test.ts', 'src/**/*.test.tsx' ], 9 9 }, 10 10 resolve: { 11 11 dedupe: [ 'react', 'react-dom', '@wordpress/element' ],