A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1import { describe, it, expect } from 'vitest';
2import {
3 THEME_PRESETS,
4 parseBasicTheme,
5 findPresetByColors,
6 themeToCssVars,
7 themeStyleBlock,
8 resolveSelectedTheme,
9 ensureContrast,
10 CUSTOM_THEME_SLUG,
11 type BasicTheme,
12 type Rgb,
13} from './themes';
14
15const luminance = ( { r, g, b }: { r: number; g: number; b: number } ) => {
16 const ch = ( c: number ) => {
17 const s = c / 255;
18 return s <= 0.03928 ? s / 12.92 : ( ( s + 0.055 ) / 1.055 ) ** 2.4;
19 };
20 return 0.2126 * ch( r ) + 0.7152 * ch( g ) + 0.0722 * ch( b );
21};
22const ratio = ( a: BasicTheme[ 'background' ], b: BasicTheme[ 'background' ] ) => {
23 const la = luminance( a ) + 0.05;
24 const lb = luminance( b ) + 0.05;
25 return la > lb ? la / lb : lb / la;
26};
27
28/** Parse an app-built `rgb(r, g, b)` token back into an Rgb (the form themeToCssVars emits). */
29const parseRgb = ( value: string ): Rgb => {
30 const m = value.match( /^rgb\((\d{1,3}), (\d{1,3}), (\d{1,3})\)$/ );
31 if ( ! m ) {
32 throw new Error( `not an rgb() token: ${ value }` );
33 }
34 return { r: Number( m[ 1 ] ), g: Number( m[ 2 ] ), b: Number( m[ 3 ] ) };
35};
36
37describe( 'THEME_PRESETS', () => {
38 it( 'ships the 8 sky phases with unique slugs', () => {
39 expect( THEME_PRESETS ).toHaveLength( 8 );
40 expect( THEME_PRESETS.map( ( p ) => p.slug ) ).toEqual( [
41 'evening',
42 'noon',
43 'dusk',
44 'afternoon',
45 'twilight',
46 'morning',
47 'sunrise',
48 'midnight',
49 ] );
50 expect( new Set( THEME_PRESETS.map( ( p ) => p.slug ) ).size ).toBe( 8 );
51 } );
52
53 it( 'every preset clears WCAG AA for body text and button text', () => {
54 for ( const preset of THEME_PRESETS ) {
55 const { background, foreground, accent, accentForeground } = preset.colors;
56 expect( ratio( background, foreground ) ).toBeGreaterThanOrEqual( 4.5 );
57 expect( ratio( accent, accentForeground ) ).toBeGreaterThanOrEqual( 4.5 );
58 }
59 } );
60
61 it( 'every preset colour carries the site.standard.theme.color#rgb union $type', () => {
62 // `site.standard.theme.basic` types each colour as a union of `site.standard.theme.color#rgb`,
63 // and atproto requires a `$type` discriminator on union members. Without it the publication
64 // record is invalid and Bluesky's AppView can't hydrate the enhanced standard.site link card.
65 for ( const preset of THEME_PRESETS ) {
66 for ( const color of [
67 preset.colors.background,
68 preset.colors.foreground,
69 preset.colors.accent,
70 preset.colors.accentForeground,
71 ] ) {
72 expect( color.$type ).toBe( 'site.standard.theme.color#rgb' );
73 }
74 }
75 } );
76
77 it( 'every preset color channel is an integer 0–255', () => {
78 for ( const preset of THEME_PRESETS ) {
79 for ( const color of [
80 preset.colors.background,
81 preset.colors.foreground,
82 preset.colors.accent,
83 preset.colors.accentForeground,
84 ] ) {
85 for ( const channel of [ color.r, color.g, color.b ] ) {
86 expect( Number.isInteger( channel ) ).toBe( true );
87 expect( channel ).toBeGreaterThanOrEqual( 0 );
88 expect( channel ).toBeLessThanOrEqual( 255 );
89 }
90 }
91 }
92 } );
93} );
94
95describe( 'theme contrast audit (every text pairing the themed reader renders)', () => {
96 // The themed reader consumes these tokens by name. Each row is a real on-screen pairing,
97 // with the WCAG threshold for how that pairing is rendered (normal text 4.5:1; the
98 // avatar/publication-logo fallback is large bold display text → 3:1). `against` is the
99 // worst-case surface the token sits on. Decorative hairline borders (`--line`/
100 // `--line-strong`) are intentionally omitted — they are not text and not required to
101 // identify a component, so WCAG exempts them (the base design ships them at ~1.2:1 too).
102 const pairings: ReadonlyArray< {
103 label: string;
104 fg: keyof ReturnType< typeof themeToCssVars > | 'accentForeground';
105 bg: keyof ReturnType< typeof themeToCssVars >;
106 min: number;
107 } > = [
108 { label: 'link text', fg: '--sun', bg: '--paper', min: 4.5 },
109 { label: 'link hover text', fg: '--sun-strong', bg: '--paper', min: 4.5 },
110 { label: 'muted secondary text', fg: '--muted', bg: '--panel', min: 4.5 },
111 { label: 'ink-soft secondary text', fg: '--ink-soft', bg: '--panel', min: 4.5 },
112 { label: 'button text', fg: '--btn-primary-fg', bg: '--btn-primary', min: 4.5 },
113 { label: 'button hover text', fg: '--btn-primary-fg', bg: '--btn-primary-hover', min: 4.5 },
114 { label: 'avatar/logo large display text', fg: '--sun', bg: '--sun-tint', min: 3 },
115 ];
116
117 for ( const preset of THEME_PRESETS ) {
118 describe( preset.slug, () => {
119 const vars = themeToCssVars( preset.colors );
120 for ( const { label, fg, bg, min } of pairings ) {
121 it( `clears WCAG for ${ label } (≥ ${ min }:1)`, () => {
122 const fgRgb = parseRgb( vars[ fg ] );
123 const bgRgb = parseRgb( vars[ bg ] );
124 expect( ratio( fgRgb, bgRgb ) ).toBeGreaterThanOrEqual( min );
125 } );
126 }
127 } );
128 }
129} );
130
131describe( 'preset stored colours (interop: the record itself must be accessible)', () => {
132 // The five themes whose accent was unreadable on its background were re-tuned (accent
133 // lightness adjusted on-hue; accentForeground re-chosen). Lock the stored values so a
134 // future edit that reintroduces an inaccessible accent fails here, and so the three
135 // already-compliant presets stay byte-identical.
136 it( 'every preset accent clears WCAG AA as a link on its own background (≥ 4.5:1)', () => {
137 for ( const preset of THEME_PRESETS ) {
138 const { background, accent } = preset.colors;
139 expect( ratio( accent, background ) ).toBeGreaterThanOrEqual( 4.5 );
140 }
141 } );
142
143 it( 'leaves the already-compliant presets (dusk, sunrise, midnight) unchanged', () => {
144 const bySlug = ( slug: string ) => THEME_PRESETS.find( ( p ) => p.slug === slug )!.colors;
145 expect( bySlug( 'dusk' ).accent ).toMatchObject( { r: 101, g: 13, b: 212 } );
146 expect( bySlug( 'sunrise' ).accent ).toMatchObject( { r: 219, g: 154, b: 177 } );
147 expect( bySlug( 'midnight' ).accent ).toMatchObject( { r: 232, g: 183, b: 255 } );
148 } );
149} );
150
151describe( 'ensureContrast', () => {
152 const black: Rgb = { r: 0, g: 0, b: 0 };
153 const white: Rgb = { r: 255, g: 255, b: 255 };
154
155 it( 'returns the colour untouched when it already meets the target', () => {
156 // Black on white is ~21:1 — already way past 4.5.
157 expect( ensureContrast( black, white, 4.5 ) ).toEqual( black );
158 } );
159
160 it( 'adjusts a too-light colour on a light background until it clears the target', () => {
161 const bg: Rgb = { r: 248, g: 247, b: 245 };
162 const start: Rgb = { r: 245, g: 182, b: 132 }; // peach, ~1.65:1 on bg
163 expect( ratio( start, bg ) ).toBeLessThan( 4.5 );
164 const fixed = ensureContrast( start, bg, 4.5 );
165 expect( ratio( fixed, bg ) ).toBeGreaterThanOrEqual( 4.5 );
166 } );
167
168 it( 'adjusts a too-dark colour on a dark background until it clears the target', () => {
169 const bg: Rgb = { r: 27, g: 27, b: 27 };
170 const start: Rgb = { r: 68, g: 35, b: 105 }; // dark purple, ~1.39:1 on bg
171 expect( ratio( start, bg ) ).toBeLessThan( 4.5 );
172 const fixed = ensureContrast( start, bg, 4.5 );
173 expect( ratio( fixed, bg ) ).toBeGreaterThanOrEqual( 4.5 );
174 } );
175
176 it( 'preserves hue while adjusting (a blue stays blue-dominant)', () => {
177 const bg: Rgb = { r: 19, g: 19, b: 19 };
178 const start: Rgb = { r: 75, g: 82, b: 255 };
179 const fixed = ensureContrast( start, bg, 4.5 );
180 expect( fixed.b ).toBeGreaterThan( fixed.r );
181 expect( fixed.b ).toBeGreaterThan( fixed.g );
182 } );
183} );
184
185describe( 'parseBasicTheme', () => {
186 it( 'accepts a valid theme and stamps $type', () => {
187 const parsed = parseBasicTheme( THEME_PRESETS[ 0 ].colors );
188 expect( parsed ).not.toBeNull();
189 expect( parsed!.$type ).toBe( 'site.standard.theme.basic' );
190 expect( parsed!.accent ).toEqual( THEME_PRESETS[ 0 ].colors.accent );
191 } );
192
193 it( 'stamps the union $type discriminator on every colour (lexicon requires it)', () => {
194 const parsed = parseBasicTheme( THEME_PRESETS[ 0 ].colors )!;
195 for ( const color of [ parsed.background, parsed.foreground, parsed.accent, parsed.accentForeground ] ) {
196 expect( color.$type ).toBe( 'site.standard.theme.color#rgb' );
197 }
198 } );
199
200 it( 'rejects undefined, non-objects, and out-of-range / non-integer channels', () => {
201 expect( parseBasicTheme( undefined ) ).toBeNull();
202 expect( parseBasicTheme( 'nope' ) ).toBeNull();
203 expect( parseBasicTheme( { background: { r: 0, g: 0, b: 0 } } ) ).toBeNull();
204 expect(
205 parseBasicTheme( {
206 background: { r: 300, g: 0, b: 0 },
207 foreground: { r: 0, g: 0, b: 0 },
208 accent: { r: 0, g: 0, b: 0 },
209 accentForeground: { r: 0, g: 0, b: 0 },
210 } )
211 ).toBeNull();
212 expect(
213 parseBasicTheme( {
214 background: { r: 1.5, g: 0, b: 0 },
215 foreground: { r: 0, g: 0, b: 0 },
216 accent: { r: 0, g: 0, b: 0 },
217 accentForeground: { r: 0, g: 0, b: 0 },
218 } )
219 ).toBeNull();
220 } );
221} );
222
223describe( 'findPresetByColors', () => {
224 it( 'reverse-matches stored colours to a preset', () => {
225 const preset = findPresetByColors( THEME_PRESETS[ 3 ].colors );
226 expect( preset?.slug ).toBe( 'afternoon' );
227 } );
228
229 it( 'returns null for none/custom colours', () => {
230 expect( findPresetByColors( null ) ).toBeNull();
231 expect( findPresetByColors( undefined ) ).toBeNull();
232 expect(
233 findPresetByColors( {
234 $type: 'site.standard.theme.basic',
235 background: { r: 1, g: 2, b: 3 },
236 foreground: { r: 4, g: 5, b: 6 },
237 accent: { r: 7, g: 8, b: 9 },
238 accentForeground: { r: 10, g: 11, b: 12 },
239 } )
240 ).toBeNull();
241 } );
242
243 it( 'round-trips a parsed theme back to its preset (the edit-prefill path)', () => {
244 // Production prefill reads `existing.basicTheme`, which came through parseBasicTheme.
245 const parsed = parseBasicTheme( THEME_PRESETS[ 6 ].colors ); // sunrise
246 expect( findPresetByColors( parsed )?.slug ).toBe( 'sunrise' );
247 } );
248} );
249
250describe( 'resolveSelectedTheme', () => {
251 const custom: BasicTheme = {
252 $type: 'site.standard.theme.basic',
253 background: { r: 1, g: 2, b: 3 },
254 foreground: { r: 4, g: 5, b: 6 },
255 accent: { r: 7, g: 8, b: 9 },
256 accentForeground: { r: 10, g: 11, b: 12 },
257 };
258
259 it( 'maps null to undefined (no theme)', () => {
260 expect( resolveSelectedTheme( null, null ) ).toBeUndefined();
261 expect( resolveSelectedTheme( null, custom ) ).toBeUndefined();
262 } );
263
264 it( 'maps a preset slug to that preset’s colours', () => {
265 expect( resolveSelectedTheme( 'dusk', null ) ).toEqual( THEME_PRESETS[ 2 ].colors );
266 } );
267
268 it( 'keeps the custom theme verbatim for the custom sentinel', () => {
269 expect( resolveSelectedTheme( CUSTOM_THEME_SLUG, custom ) ).toEqual( custom );
270 // No custom theme to keep → no theme rather than a dangling sentinel.
271 expect( resolveSelectedTheme( CUSTOM_THEME_SLUG, null ) ).toBeUndefined();
272 } );
273
274 it( 'maps an unknown slug to undefined (preset removed) rather than crashing', () => {
275 expect( resolveSelectedTheme( 'no-such-preset', null ) ).toBeUndefined();
276 } );
277} );
278
279describe( 'themeToCssVars', () => {
280 const vars = themeToCssVars( THEME_PRESETS[ 0 ].colors ); // evening
281
282 it( 'maps the core tokens to rgb() strings', () => {
283 expect( vars[ '--paper' ] ).toBe( 'rgb(27, 27, 27)' );
284 expect( vars[ '--ink' ] ).toBe( 'rgb(240, 240, 240)' );
285 // evening's accent is already AA on its dark background, so --sun is the raw accent.
286 expect( vars[ '--sun' ] ).toBe( 'rgb(157, 111, 207)' );
287 expect( vars[ '--btn-primary' ] ).toBe( 'rgb(157, 111, 207)' );
288 expect( vars[ '--btn-primary-fg' ] ).toBe( 'rgb(0, 0, 0)' );
289 } );
290
291 it( 'derives intermediate tokens as in-range rgb() strings', () => {
292 for ( const key of [ '--muted', '--line', '--line-strong', '--sun-tint', '--btn-primary-hover' ] ) {
293 expect( vars[ key ] ).toMatch( /^rgb\(\d{1,3}, \d{1,3}, \d{1,3}\)$/ );
294 }
295 } );
296
297 it( 'blends muted as foreground OVER background (direction locked)', () => {
298 // evening: bg 27, fg 240 → mix(fg, bg, 0.55) = round(240*.55 + 27*.45) = round(144.15) = 144.
299 // If the blend direction inverted (bg over fg) this would be ~123 — caught here.
300 expect( vars[ '--muted' ] ).toBe( 'rgb(144, 144, 144)' );
301 } );
302
303 it( 'carries accentForeground into --btn-primary-fg', () => {
304 const sunrise = themeToCssVars( THEME_PRESETS[ 6 ].colors ); // accentForeground = black
305 expect( sunrise[ '--btn-primary-fg' ] ).toBe( 'rgb(0, 0, 0)' );
306 } );
307} );
308
309describe( 'themeStyleBlock', () => {
310 it( 'returns a :root override style tag for a theme', () => {
311 const html = themeStyleBlock( THEME_PRESETS[ 1 ].colors ); // noon
312 expect( html ).toContain( '<style>' );
313 expect( html ).toContain( ':root' );
314 expect( html ).toContain( '--paper: rgb(248, 247, 245)' );
315 expect( html ).toContain( '.btn--primary' );
316 } );
317
318 it( 'returns an empty string when there is no theme', () => {
319 expect( themeStyleBlock( null ) ).toBe( '' );
320 expect( themeStyleBlock( undefined ) ).toBe( '' );
321 } );
322
323 it( 'outranks the global :root defaults so the theme wins regardless of load order', () => {
324 // The light + dark design tokens live on `:root` in global.css, which Astro bundles and
325 // links into the head AFTER the page's injected <style>. A bare `:root` override has
326 // identical specificity (0,1,0), so the cascade falls to source order — and the later
327 // global.css wins, silently dropping the publication theme in BOTH colour schemes
328 // (verified on production; Decision 0012). The override must therefore outrank a single
329 // `:root`: `:root:root` (0,2,0) beats both the plain and the
330 // `@media (prefers-color-scheme: dark) :root` defaults (0,1,0) in any order.
331 const html = themeStyleBlock( THEME_PRESETS[ 4 ].colors ); // twilight
332 expect( html ).toMatch( /<style>:root:root\s*\{/ );
333 } );
334
335 it( 'never emits anything but digits/commas inside rgb() (no injection surface)', () => {
336 const html = themeStyleBlock( THEME_PRESETS[ 2 ].colors );
337 const matches = html.match( /rgb\([^)]*\)/g ) ?? [];
338 expect( matches.length ).toBeGreaterThan( 0 );
339 for ( const m of matches ) {
340 expect( m ).toMatch( /^rgb\(\d{1,3}, \d{1,3}, \d{1,3}\)$/ );
341 }
342 } );
343} );