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.

at trunk 14 kB View raw
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} );