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.

Make every publication theme clear WCAG AA contrast

The eight sky-phase presets only had body-text and button-text contrast under
test, but the themed reader renders accent as the link colour (accent-on-
background) and derives muted/secondary text and the button-hover fill — none of
which were checked. Auditing every text pairing found 5 of 8 themes with
effectively unreadable links and 5 of 8 with sub-AA muted text.

Fix it at both layers:

- Bake contrast-safe raw colours into the 5 failing presets. The stored
basicTheme record carries only 4 colours and other standard.site readers map
accent -> link directly (Decision 0012 interop), so the stored accent must
itself clear AA on its background. Each failing accent's lightness is adjusted
on-hue until it clears 4.5:1, with accentForeground re-chosen accordingly.
dusk/sunrise/midnight were already compliant and are unchanged.
- Make themeToCssVars derivations contrast-aware via a new ensureContrast helper
(HSL-lightness, hue preserved): links, link-hover, muted, ink-soft, and the
button-hover fill are clamped to AA against their worst-case surface. This also
protects arbitrary custom themes, not just the curated presets.

Decorative hairline borders stay low-contrast (as in the base design) and are
documented as WCAG-exempt. No CSS changes: the reader already consumes these
token names. Extends themes.test.ts with a per-preset audit over every text
pairing plus ensureContrast unit tests.

+268 -32
+16 -10
docs/superpowers/specs/2026-06-09-publication-theme-contrast-design.md
··· 88 88 89 89 | token | role | target | derived against | 90 90 |---|---|---|---| 91 + | `--sun` / `--ember` | accent text / links / focus rings | 4.5 | `--paper` | 92 + | `--sun-strong` | link hover text | 4.5 | `--paper` | 91 93 | `--muted` | secondary text | 4.5 | `--panel` (most fg-ward surface) | 92 94 | `--ink-soft` | secondary text | 4.5 | `--panel` | 93 - | `--sun-strong` | link hover / pill-on-tint text | 4.5 | `--sun-tint` | 94 - | `--sun` / `--ember` | accent text / links / focus rings | (already ≥ 4.5 vs paper from baked accent) | — | 95 95 | `--btn-primary-hover` | button hover fill | 4.5 (vs `accentForeground`) | — | 96 96 | `--paper`, `--paper-raised`, `--panel`, `--ink`, `--btn-primary`, `--btn-primary-fg`, `--sun-tint` | surfaces / body / button fill+text | unchanged | — | 97 97 | `--line`, `--line-strong` | decorative hairline borders | **exempt** | — | 98 98 99 - `--sun` stays the raw (baked) accent — it now clears ≥ 4.5 on `--paper`, so links, the 100 - `border-left` editorial accent, and focus rings (which need only ≥ 3:1) are all covered. 101 - Pill/tag text sits on the more accent-ward `--sun-tint`, so that text path uses 102 - `--sun-strong`, which is derived to clear 4.5 against the tint. 99 + Even though the baked accents are already ≥ 4.5 on `--paper`, `--sun`/`--sun-strong` are 100 + still routed through `ensureContrast` so that *arbitrary* themes (the custom-theme path, 101 + `resolveSelectedTheme(CUSTOM_THEME_SLUG, …)`) also render accessibly. For the baked presets 102 + this is a no-op. 103 + 104 + `--sun` stays the raw (baked) accent — it clears ≥ 4.5 on `--paper`, so links, the 105 + `border-left` editorial accent, and focus rings (which need only ≥ 3:1) are covered. The 106 + only place `--sun` sits on the more accent-ward `--sun-tint` is the **large** display text 107 + of the avatar / publication-logo fallback (2.6rem / 1.4rem, weight 700) — WCAG large-text, 108 + so its bar is ≥ 3:1, which all baked accents clear (≥ 3.5:1). No CSS retargeting needed. 103 109 104 110 ### 3. No CSS changes 105 111 ··· 120 126 Extend `themes.test.ts`: 121 127 122 128 1. **Per-preset text-pairing audit** — for every preset, assert ≥ 4.5 for: link 123 - (`--sun`/`--paper`), link hover (`--sun-strong`/`--paper`), pill text 124 - (`--sun-strong`/`--sun-tint`), muted (`--muted`/`--panel`), ink-soft 125 - (`--ink-soft`/`--panel`), button (`accentForeground`/`--btn-primary`), button hover 126 - (`accentForeground`/`--btn-primary-hover`). This is the failing test written first. 129 + (`--sun`/`--paper`), link hover (`--sun-strong`/`--paper`), muted (`--muted`/`--panel`), 130 + ink-soft (`--ink-soft`/`--panel`), button (`accentForeground`/`--btn-primary`), button 131 + hover (`accentForeground`/`--btn-primary-hover`); and ≥ 3:1 for the large avatar/logo 132 + display text (`--sun`/`--sun-tint`). This is the failing test written first. 127 133 2. **Stability assertion** — `dusk`, `sunrise`, `midnight` raw colors are unchanged. 128 134 3. **`ensureContrast` unit tests** — returns input untouched when already compliant; 129 135 reaches target otherwise; preserves hue direction.
+107 -5
src/lib/publish/themes.test.ts
··· 6 6 themeToCssVars, 7 7 themeStyleBlock, 8 8 resolveSelectedTheme, 9 + ensureContrast, 9 10 CUSTOM_THEME_SLUG, 10 11 type BasicTheme, 12 + type Rgb, 11 13 } from './themes'; 12 14 13 15 const luminance = ( { r, g, b }: { r: number; g: number; b: number } ) => { ··· 21 23 const la = luminance( a ) + 0.05; 22 24 const lb = luminance( b ) + 0.05; 23 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). */ 29 + const 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 ] ) }; 24 35 }; 25 36 26 37 describe( 'THEME_PRESETS', () => { ··· 81 92 } ); 82 93 } ); 83 94 95 + describe( '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 + 131 + describe( '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 + 151 + describe( '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 + 84 185 describe( 'parseBasicTheme', () => { 85 186 it( 'accepts a valid theme and stamps $type', () => { 86 187 const parsed = parseBasicTheme( THEME_PRESETS[ 0 ].colors ); ··· 181 282 it( 'maps the core tokens to rgb() strings', () => { 182 283 expect( vars[ '--paper' ] ).toBe( 'rgb(27, 27, 27)' ); 183 284 expect( vars[ '--ink' ] ).toBe( 'rgb(240, 240, 240)' ); 184 - expect( vars[ '--sun' ] ).toBe( 'rgb(68, 35, 105)' ); 185 - expect( vars[ '--btn-primary' ] ).toBe( 'rgb(68, 35, 105)' ); 186 - expect( vars[ '--btn-primary-fg' ] ).toBe( 'rgb(255, 255, 255)' ); 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)' ); 187 289 } ); 188 290 189 291 it( 'derives intermediate tokens as in-range rgb() strings', () => { ··· 199 301 } ); 200 302 201 303 it( 'carries accentForeground into --btn-primary-fg', () => { 202 - const noon = themeToCssVars( THEME_PRESETS[ 1 ].colors ); // accentForeground = black 203 - expect( noon[ '--btn-primary-fg' ] ).toBe( 'rgb(0, 0, 0)' ); 304 + const sunrise = themeToCssVars( THEME_PRESETS[ 6 ].colors ); // accentForeground = black 305 + expect( sunrise[ '--btn-primary-fg' ] ).toBe( 'rgb(0, 0, 0)' ); 204 306 } ); 205 307 } ); 206 308
+145 -17
src/lib/publish/themes.ts
··· 60 60 * The eight sky-phase presets (Twenty Twenty-Five colour palettes). Mapping per palette: 61 61 * `background ← base`, `foreground ← contrast`, `accent ←` the palette's signature accent, 62 62 * `accentForeground ←` whichever of black/white clears WCAG AA on that accent. 63 + * 64 + * Each stored `accent` clears WCAG AA (≥ 4.5:1) **as a link on its own `background`**, not just 65 + * as a button fill behind `accentForeground` — because the standard `basicTheme` record carries 66 + * only these four colours, and any standard.site reader maps `accent → link colour` directly 67 + * (Decision 0012's interop intent). Five palettes whose original accent was unreadable on its 68 + * background had the accent's lightness adjusted on-hue (hue + saturation preserved) until it 69 + * cleared 4.5:1, with `accentForeground` re-chosen as whichever of black/white clears the new 70 + * accent. The contrast guarantee is locked by `themes.test.ts`. (See the theme-contrast design 71 + * doc, 2026-06-09.) 63 72 */ 64 73 export const THEME_PRESETS: readonly ThemePreset[] = [ 65 - preset( 'evening', 'Evening', rgb( 27, 27, 27 ), rgb( 240, 240, 240 ), rgb( 68, 35, 105 ), rgb( 255, 255, 255 ) ), 66 - preset( 'noon', 'Noon', rgb( 248, 247, 245 ), rgb( 25, 25, 25 ), rgb( 245, 182, 132 ), rgb( 0, 0, 0 ) ), 74 + preset( 'evening', 'Evening', rgb( 27, 27, 27 ), rgb( 240, 240, 240 ), rgb( 157, 111, 207 ), rgb( 0, 0, 0 ) ), 75 + preset( 'noon', 'Noon', rgb( 248, 247, 245 ), rgb( 25, 25, 25 ), rgb( 178, 87, 15 ), rgb( 255, 255, 255 ) ), 67 76 preset( 'dusk', 'Dusk', rgb( 226, 226, 226 ), rgb( 59, 59, 59 ), rgb( 101, 13, 212 ), rgb( 255, 255, 255 ) ), 68 - preset( 'afternoon', 'Afternoon', rgb( 218, 231, 189 ), rgb( 81, 96, 40 ), rgb( 199, 246, 66 ), rgb( 0, 0, 0 ) ), 69 - preset( 'twilight', 'Twilight', rgb( 19, 19, 19 ), rgb( 255, 255, 255 ), rgb( 75, 82, 255 ), rgb( 255, 255, 255 ) ), 70 - preset( 'morning', 'Morning', rgb( 223, 220, 215 ), rgb( 25, 25, 25 ), rgb( 122, 155, 219 ), rgb( 0, 0, 0 ) ), 77 + preset( 'afternoon', 'Afternoon', rgb( 218, 231, 189 ), rgb( 81, 96, 40 ), rgb( 82, 108, 5 ), rgb( 255, 255, 255 ) ), 78 + preset( 'twilight', 'Twilight', rgb( 19, 19, 19 ), rgb( 255, 255, 255 ), rgb( 103, 109, 255 ), rgb( 0, 0, 0 ) ), 79 + preset( 'morning', 'Morning', rgb( 223, 220, 215 ), rgb( 25, 25, 25 ), rgb( 48, 93, 178 ), rgb( 255, 255, 255 ) ), 71 80 preset( 'sunrise', 'Sunrise', rgb( 51, 6, 22 ), rgb( 255, 255, 255 ), rgb( 219, 154, 177 ), rgb( 0, 0, 0 ) ), 72 81 preset( 'midnight', 'Midnight', rgb( 68, 51, 166 ), rgb( 121, 243, 177 ), rgb( 232, 183, 255 ), rgb( 0, 0, 0 ) ), 73 82 ]; ··· 171 180 172 181 const css = ( { r, g, b }: Rgb ): string => `rgb(${ r }, ${ g }, ${ b })`; 173 182 183 + /** WCAG relative luminance of an sRGB colour (0 = black, 1 = white). */ 184 + const relativeLuminance = ( { r, g, b }: Rgb ): number => { 185 + const ch = ( c: number ): number => { 186 + const s = c / 255; 187 + return s <= 0.03928 ? s / 12.92 : ( ( s + 0.055 ) / 1.055 ) ** 2.4; 188 + }; 189 + return 0.2126 * ch( r ) + 0.7152 * ch( g ) + 0.0722 * ch( b ); 190 + }; 191 + 192 + /** WCAG contrast ratio between two colours (1 = identical, 21 = black vs white). */ 193 + const contrastRatio = ( a: Rgb, b: Rgb ): number => { 194 + const la = relativeLuminance( a ) + 0.05; 195 + const lb = relativeLuminance( b ) + 0.05; 196 + return la > lb ? la / lb : lb / la; 197 + }; 198 + 199 + const rgbToHsl = ( { r, g, b }: Rgb ): { h: number; s: number; l: number } => { 200 + const rn = r / 255; 201 + const gn = g / 255; 202 + const bn = b / 255; 203 + const max = Math.max( rn, gn, bn ); 204 + const min = Math.min( rn, gn, bn ); 205 + const l = ( max + min ) / 2; 206 + if ( max === min ) { 207 + return { h: 0, s: 0, l }; 208 + } 209 + const d = max - min; 210 + const s = l > 0.5 ? d / ( 2 - max - min ) : d / ( max + min ); 211 + let h: number; 212 + switch ( max ) { 213 + case rn: 214 + h = ( gn - bn ) / d + ( gn < bn ? 6 : 0 ); 215 + break; 216 + case gn: 217 + h = ( bn - rn ) / d + 2; 218 + break; 219 + default: 220 + h = ( rn - gn ) / d + 4; 221 + } 222 + return { h: h / 6, s, l }; 223 + }; 224 + 225 + const hslToRgb = ( { h, s, l }: { h: number; s: number; l: number } ): Rgb => { 226 + if ( s === 0 ) { 227 + const v = clamp255( l * 255 ); 228 + return { r: v, g: v, b: v }; 229 + } 230 + const hue2rgb = ( p: number, q: number, t: number ): number => { 231 + if ( t < 0 ) { 232 + t += 1; 233 + } 234 + if ( t > 1 ) { 235 + t -= 1; 236 + } 237 + if ( t < 1 / 6 ) { 238 + return p + ( q - p ) * 6 * t; 239 + } 240 + if ( t < 1 / 2 ) { 241 + return q; 242 + } 243 + if ( t < 2 / 3 ) { 244 + return p + ( q - p ) * ( 2 / 3 - t ) * 6; 245 + } 246 + return p; 247 + }; 248 + const q = l < 0.5 ? l * ( 1 + s ) : l + s - l * s; 249 + const p = 2 * l - q; 250 + return { 251 + r: clamp255( hue2rgb( p, q, h + 1 / 3 ) * 255 ), 252 + g: clamp255( hue2rgb( p, q, h ) * 255 ), 253 + b: clamp255( hue2rgb( p, q, h - 1 / 3 ) * 255 ), 254 + }; 255 + }; 256 + 174 257 /** 175 - * Map a `BasicTheme` to the SkyPress design tokens it overrides. Background/foreground/accent 176 - * families map directly; borders + muted text are deterministic blends of fg over bg so the 177 - * editorial hierarchy survives any palette. A publisher's palette is a fixed identity, so this 178 - * intentionally overrides both the light and dark `:root` defaults. 258 + * Ensure `color` clears `target` contrast against `against`, preserving hue + saturation by 259 + * adjusting only HSL lightness. Returns `color` untouched when it already clears the target; 260 + * otherwise moves its lightness toward whichever pole (black or white) raises contrast against 261 + * `against`, by the minimal amount that reaches `target`. Pure black/white clears ≥ 4.5:1 262 + * against any colour, so for the WCAG-AA targets used here this always converges. 263 + * 264 + * This guards the read path against *arbitrary* themes (the custom-theme picker path), not just 265 + * the curated presets — those bake in already-compliant colours, so this is a no-op for them. 266 + */ 267 + export function ensureContrast( color: Rgb, against: Rgb, target: number ): Rgb { 268 + if ( contrastRatio( color, against ) >= target ) { 269 + return color; 270 + } 271 + const white: Rgb = { r: 255, g: 255, b: 255 }; 272 + const black: Rgb = { r: 0, g: 0, b: 0 }; 273 + const poleL = contrastRatio( white, against ) >= contrastRatio( black, against ) ? 1 : 0; 274 + const { h, s, l } = rgbToHsl( color ); 275 + // Contrast vs a fixed background is monotonic as lightness moves toward the better pole, so 276 + // binary-search the smallest move `t` in [0, 1] (l → poleL) that reaches the target. 277 + let lo = 0; 278 + let hi = 1; 279 + for ( let i = 0; i < 30; i++ ) { 280 + const mid = ( lo + hi ) / 2; 281 + const candidate = hslToRgb( { h, s, l: l + ( poleL - l ) * mid } ); 282 + if ( contrastRatio( candidate, against ) >= target ) { 283 + hi = mid; 284 + } else { 285 + lo = mid; 286 + } 287 + } 288 + return hslToRgb( { h, s, l: l + ( poleL - l ) * hi } ); 289 + } 290 + 291 + const AA = 4.5; 292 + 293 + /** 294 + * Map a `BasicTheme` to the SkyPress design tokens it overrides. Surfaces (paper/panel) and 295 + * the body/button colours map directly; the *text* tokens are clamped to clear WCAG AA against 296 + * the worst-case surface they render on (`ensureContrast`), so links and secondary text stay 297 + * readable in every palette. Decorative hairline borders (`--line`/`--line-strong`) are 298 + * deliberately low-contrast (as in the base design) and exempt. A publisher's palette is a 299 + * fixed identity, so this intentionally overrides both the light and dark `:root` defaults. 179 300 */ 180 301 export function themeToCssVars( theme: BasicTheme ): Record< string, string > { 181 302 const { background: bg, foreground: fg, accent, accentForeground } = theme; 303 + const panel = mix( fg, bg, 0.06 ); 304 + const sunTint = mix( accent, bg, 0.2 ); 182 305 return { 183 306 '--paper': css( bg ), 184 307 '--paper-raised': css( mix( fg, bg, 0.04 ) ), 185 - '--panel': css( mix( fg, bg, 0.06 ) ), 308 + '--panel': css( panel ), 186 309 '--ink': css( fg ), 187 - '--ink-soft': css( mix( fg, bg, 0.8 ) ), 188 - '--muted': css( mix( fg, bg, 0.55 ) ), 310 + // Secondary text: blend fg over bg for the editorial hierarchy, then clamp to AA against 311 + // the most fg-ward surface it sits on (`--panel`), which also covers `--paper`. 312 + '--ink-soft': css( ensureContrast( mix( fg, bg, 0.8 ), panel, AA ) ), 313 + '--muted': css( ensureContrast( mix( fg, bg, 0.55 ), panel, AA ) ), 189 314 '--line': css( mix( fg, bg, 0.14 ) ), 190 315 '--line-strong': css( mix( fg, bg, 0.26 ) ), 191 - '--sun': css( accent ), 192 - '--sun-strong': css( mix( accent, fg, 0.82 ) ), 193 - '--sun-tint': css( mix( accent, bg, 0.2 ) ), 316 + // Accent as text (links, the editorial accent) and its hover, clamped to AA on the paper. 317 + '--sun': css( ensureContrast( accent, bg, AA ) ), 318 + '--sun-strong': css( ensureContrast( mix( accent, fg, 0.82 ), bg, AA ) ), 319 + '--sun-tint': css( sunTint ), 320 + // Button fill keeps the raw accent (already AA behind accentForeground); the hover fill is 321 + // clamped so accentForeground stays AA on it. 194 322 '--btn-primary': css( accent ), 195 - '--btn-primary-hover': css( mix( accent, fg, 0.85 ) ), 323 + '--btn-primary-hover': css( ensureContrast( mix( accent, fg, 0.85 ), accentForeground, AA ) ), 196 324 '--btn-primary-fg': css( accentForeground ), 197 - '--ember': css( accent ), 325 + '--ember': css( ensureContrast( accent, bg, AA ) ), 198 326 }; 199 327 } 200 328