diff --git a/assets/css/variables.css b/assets/css/variables.css index 9148ef58..7deb6ac8 100644 --- a/assets/css/variables.css +++ b/assets/css/variables.css @@ -539,10 +539,16 @@ body.os-active { * `--os-ui-fg-on-accent`, which the shell reads as "light text on * a dark surface" in seventy-odd places that are not the accent. * Starlight here for Pulse; the accent picker rewrites it inline - * to Void whenever the chosen accent is light (Starlight, Sirius, - * Nebula, Lagoon), so the pair always agree. + * to Void whenever Void has more contrast on the chosen accent + * (Nebula, Sirius, Lagoon, Starlight, Teal, Emerald, Amber), so + * the pair always agree. */ --os-ui-accent-ink: #fffbff; + /* + * The accent as text, and its hover. Nebula is Pulse's pair; for any + * other pick the accent picker writes this and `--os-link` inline, + * lifted toward Starlight until it reads at 4.5:1 on Astro. + */ --os-ui-accent-strong: #ec9bff; --os-ui-accent-soft: color-mix(in srgb, var(--os-ui-accent-dim) 14%, transparent); /* @@ -1046,8 +1052,7 @@ body.os-active { --os-ui-notice-neutral-bg: rgba(255, 251, 255, 0.06); --os-ui-notice-neutral-border: rgba(255, 251, 255, 0.14); /* - * A link inside a notice reads Nebula, the same value `--os-link` - * gives every other shell link — NOT the accent. + * A link inside a notice reads Starlight, NOT the accent. * * Undeclared, the base fell through to `--wp-admin-theme-color`, * which OpenStation Preferences → Appearance writes inline on `` diff --git a/docs/desktop-themes.md b/docs/desktop-themes.md index e6dcbaca..35222b3d 100644 --- a/docs/desktop-themes.md +++ b/docs/desktop-themes.md @@ -415,8 +415,8 @@ kit's own stylesheets never hard-wire it to a decorative surface. | `--os-ui-hover` | Row / tile hover wash | | `--os-ui-scrim` | Modal + overlay backdrop | | `--os-ui-accent` | Primary action | -| `--os-ui-accent-strong` | Its hover / active state | -| `--os-ui-accent-ink` | Ink on the accent fill: the tick in a checked box, the dot in a radio, a primary button's label. **Not `--os-ui-fg-on-accent`**, which the shell reads as light text on any dark surface (toasts, dock labels, tile captions); accent fills read this first and fall back to that. The palette declares Starlight and Legacy `#fff`, but the accent picker writes it inline next to `--os-ui-accent` (Void for a light accent, Starlight otherwise), so inside the shell the user's pick decides it. | +| `--os-ui-accent-strong`, `--os-link` | The accent as text (links, labels) and the accent's hover state. The palette declares Nebula, and the accent picker writes both inline on ``: the picked accent when it reads at 4.5:1 on the palette's surfaces, otherwise the accent mixed toward Starlight until it does (Pulse keeps Nebula). The picker does not write them while a desktop theme is worn, so a theme that changes the surfaces declares its own. | +| `--os-ui-accent-ink` | Ink on the accent fill: the tick in a checked box, the dot in a radio, a primary button's label. **Not `--os-ui-fg-on-accent`**, which the shell reads as light text on any dark surface (toasts, dock labels, tile captions); accent fills read this first and fall back to that. The palette declares Starlight and Legacy `#fff`, but the accent picker writes it inline next to `--os-ui-accent` (Void when it has more contrast on the accent than Starlight, Starlight otherwise; Pulse keeps Starlight), so inside the shell the user's pick decides it. | | `--os-ui-danger`, `--os-ui-danger-hover` | Destructive actions | | `--os-ui-warning-fg`, `--os-ui-warning-bg`, `--os-ui-warning-border` | Warning notices | | `--os-ui-info-fg`, `--os-ui-info-bg` | Info notices | diff --git a/src/settings/index.ts b/src/settings/index.ts index 610f4ab7..98ce603a 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -67,31 +67,80 @@ import { registerCustomImageIfPresent, } from './wallpaper-defs'; -/** - * Whether text on a fill of this colour should be dark. - * - * WCAG relative luminance, with the threshold set between the two - * brand accents that sit closest to it: Pulse (0.32) keeps the - * Starlight ink the brand pairs it with, Lagoon (0.37), Nebula (0.49), - * Sirius (0.78) and Starlight itself take Void. Anything that is not - * a six-digit hex reads as dark, which keeps the palette's own - * Starlight ink. - */ -export function accentWantsDarkInk( hex: string ): boolean { +/** WCAG relative luminance of a six-digit hex, or `null` for anything else. */ +function relativeLuminance( hex: string ): number | null { const match = /^#?([0-9a-f]{6})$/i.exec( hex.trim() ); if ( ! match ) { - return false; + return null; } const linear = ( pair: string ): number => { const s = parseInt( pair, 16 ) / 255; return s <= 0.03928 ? s / 12.92 : ( ( s + 0.055 ) / 1.055 ) ** 2.4; }; const hexRgb = match[ 1 ]; - const luminance = + return ( 0.2126 * linear( hexRgb.slice( 0, 2 ) ) + 0.7152 * linear( hexRgb.slice( 2, 4 ) ) + - 0.0722 * linear( hexRgb.slice( 4, 6 ) ); - return luminance > 0.35; + 0.0722 * linear( hexRgb.slice( 4, 6 ) ) + ); +} + +/** + * Whether text on a fill of this colour should be dark. + * + * WCAG relative luminance against the crossover where Void (#0c0b0f) + * and Starlight (#fffbff) have equal contrast on the fill: + * sqrt( ( 0.9747 + 0.05 ) * ( 0.0035 + 0.05 ) ) - 0.05, about 0.184. + * Above it Void reads better, below it Starlight does. Pulse is the + * one exception and keeps the Starlight ink the brand pairs it with. + * Anything that is not a six-digit hex reads as dark, which keeps the + * palette's own Starlight ink. + */ +export function accentWantsDarkInk( hex: string ): boolean { + if ( /^#?f252fc$/i.test( hex.trim() ) ) { + return false; + } + const luminance = relativeLuminance( hex ); + return luminance !== null && luminance > 0.184; +} + +/** + * The color link text takes for this accent on the shell's dark + * surfaces, or `null` to keep the palette's own. + * + * The accent itself when it already reads at WCAG AA (4.5:1) against + * Astro, the lightest surface the palette puts text on, so Nebula, + * Sirius, Lagoon and Starlight come back unchanged. A darker accent + * (WordPress Blue, Indigo, Rose, and slightly Teal, Emerald and Amber) is mixed toward Starlight, five + * percent at a time, until it does. Anything that is not a six-digit + * hex returns `null`. + */ +export function accentLinkColor( hex: string ): string | null { + const match = /^#?([0-9a-f]{6})$/i.exec( hex.trim() ); + if ( ! match ) { + return null; + } + const astro = relativeLuminance( '#33303a' ) as number; + const starlight = [ 0xff, 0xfb, 0xff ]; + const rgb = [ 0, 2, 4 ].map( ( i ) => + parseInt( match[ 1 ].slice( i, i + 2 ), 16 ), + ); + for ( let step = 0; step < 20; step++ ) { + const mixed = + '#' + + rgb + .map( ( c, i ) => + Math.round( c + ( ( starlight[ i ] - c ) * step ) / 20 ) + .toString( 16 ) + .padStart( 2, '0' ), + ) + .join( '' ); + const luminance = relativeLuminance( mixed ) as number; + if ( ( luminance + 0.05 ) / ( astro + 0.05 ) >= 4.5 ) { + return mixed; + } + } + return '#fffbff'; } /** Options for {@link OsSettings.update}. */ @@ -451,6 +500,35 @@ export class OsSettings { // through `apply()`. `applyDesktopTheme` dedupes on the active // id, so the repeated calls this makes cost two comparisons. applyDesktopTheme( this.state.desktopTheme ); + + /* + * Link text: `--os-link`, and `--os-ui-accent-strong`, which + * the shell reads as the accent's text and hover color. The + * palette declares both at Nebula, the brand's pair for Pulse, + * so without this write every link in My WordPress, the + * Preferences About card and the Content Graph stayed pink + * whatever accent was picked. + * + * Unlike the accent, these are not written on the shell and + * not written at all while a desktop theme is worn. A link is + * text on a surface, and `accentLinkColor()` only knows the + * palette's dark surfaces; a theme brings its own (Legacy is + * white), so it keeps the link color it declares. Pulse keeps + * the palette's Nebula, the same way it keeps its hand-mixed + * dim. + */ + const linkColor = + shell.hasAttribute( 'data-os-desktop-theme' ) || + accentValue.toLowerCase() === BRAND_ACCENT + ? null + : accentLinkColor( accentValue ); + for ( const token of [ '--os-link', '--os-ui-accent-strong' ] ) { + if ( linkColor === null ) { + root.style.removeProperty( token ); + } else { + root.style.setProperty( token, linkColor ); + } + } } /** diff --git a/tests/vitest/accent-ink.test.ts b/tests/vitest/accent-ink.test.ts new file mode 100644 index 00000000..930ebdca --- /dev/null +++ b/tests/vitest/accent-ink.test.ts @@ -0,0 +1,64 @@ +/** + * The ink on an accent fill (`--os-ui-accent-ink`): the label of a + * primary button, the tick in a checked box. + * + * `accentWantsDarkInk` picks between Void and Starlight. The pick has + * to be the one that reads better on the fill, measured as the WCAG + * contrast ratio, for every swatch and for a custom colour. Pulse is + * the exception: the brand pairs it with Starlight. + */ +import { describe, expect, test } from 'vitest'; +import { accentWantsDarkInk } from '../../src/settings'; + +const VOID = '#0c0b0f'; +const STARLIGHT = '#fffbff'; + +function luminance( hex: string ): number { + const channel = ( i: number ): number => { + const s = parseInt( hex.slice( 1 + i * 2, 3 + i * 2 ), 16 ) / 255; + return s <= 0.03928 ? s / 12.92 : ( ( s + 0.055 ) / 1.055 ) ** 2.4; + }; + return 0.2126 * channel( 0 ) + 0.7152 * channel( 1 ) + 0.0722 * channel( 2 ); +} + +function contrast( a: string, b: string ): number { + const [ hi, lo ] = [ luminance( a ), luminance( b ) ].sort( ( x, y ) => y - x ); + return ( hi + 0.05 ) / ( lo + 0.05 ); +} + +describe( 'accentWantsDarkInk', () => { + test.each( [ + [ 'Nebula', '#ec9bff' ], + [ 'Sirius', '#9af2ff' ], + [ 'Lagoon', '#9f98ff' ], + [ 'Starlight', '#fffbff' ], + [ 'WordPress Blue', '#2271b1' ], + [ 'Indigo', '#3858e9' ], + [ 'Teal', '#04a4cc' ], + [ 'Emerald', '#059669' ], + [ 'Amber', '#d97706' ], + [ 'Rose', '#e11d48' ], + [ 'custom red', '#ff0000' ], + [ 'custom green', '#00a000' ], + [ 'custom navy', '#000080' ], + ] )( '%s (%s) gets the ink with more contrast', ( _label, hex ) => { + const ink = accentWantsDarkInk( hex ) ? VOID : STARLIGHT; + const other = ink === VOID ? STARLIGHT : VOID; + expect( contrast( hex, ink ) ).toBeGreaterThan( contrast( hex, other ) ); + } ); + + test( 'a custom #ff0000 primary button passes AA', () => { + const ink = accentWantsDarkInk( '#ff0000' ) ? VOID : STARLIGHT; + expect( contrast( '#ff0000', ink ) ).toBeGreaterThanOrEqual( 4.5 ); + } ); + + test( 'Pulse keeps the Starlight ink the brand pairs it with', () => { + expect( accentWantsDarkInk( '#f252fc' ) ).toBe( false ); + expect( accentWantsDarkInk( '#F252FC' ) ).toBe( false ); + } ); + + test( 'anything that is not a six-digit hex keeps Starlight', () => { + expect( accentWantsDarkInk( 'red' ) ).toBe( false ); + expect( accentWantsDarkInk( '#fff' ) ).toBe( false ); + } ); +} ); diff --git a/tests/vitest/os-settings-accent-link-apply.test.ts b/tests/vitest/os-settings-accent-link-apply.test.ts new file mode 100644 index 00000000..b7a73e53 --- /dev/null +++ b/tests/vitest/os-settings-accent-link-apply.test.ts @@ -0,0 +1,115 @@ +/** + * OS Settings → Appearance → Accent, at the apply pass: link text. + * + * The palette declares `--os-link` and `--os-ui-accent-strong` at + * Nebula, the brand's pair for Pulse. The picker wrote the accent, its + * ink and its dim, and left these two alone, so every link and every + * accent-strong label stayed pink whichever swatch was picked. These + * pin that each built-in swatch moves them, to a color that still + * reads at WCAG AA on the palette's dark surfaces. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { _resetAllSharedStoresForTests } from '../../src/shared-store'; +import { OsSettings } from '../../src/settings'; +import { DEFAULT_ACCENTS, STORAGE_KEY } from '../../src/settings/constants'; +import { clearHooksStub, installHooksStub } from './helpers/hooks-stub'; +import type { WallpaperLayer } from '../../src/wallpapers/layer'; + +const CSS = readFileSync( + resolve( __dirname, '../../assets/css/variables.css' ), + 'utf8', +); +const TOKENS = [ '--os-link', '--os-ui-accent-strong' ]; + +function declared( token: string ): string { + const match = new RegExp( `\\n\\t${ token }:\\s*([^;]+);` ).exec( CSS ); + return match ? match[ 1 ].trim() : ''; +} + +function luminance( hex: string ): number { + const rgb = /^#([0-9a-f]{6})$/i.exec( hex )![ 1 ]; + const [ r, g, b ] = [ 0, 2, 4 ].map( ( i ) => { + const s = parseInt( rgb.slice( i, i + 2 ), 16 ) / 255; + return s <= 0.03928 ? s / 12.92 : ( ( s + 0.055 ) / 1.055 ) ** 2.4; + } ); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrast( a: string, b: string ): number { + const [ hi, lo ] = [ luminance( a ), luminance( b ) ].sort( ( x, y ) => y - x ); + return ( hi + 0.05 ) / ( lo + 0.05 ); +} + +// The palette's surfaces text sits on: Void, Obsidian, Astro. +const SURFACES = [ + '--os-ui-surface-sunken', + '--os-ui-surface', + '--os-ui-surface-elevated', +].map( declared ); + +function applyAccent( accent: string ): void { + window.localStorage.setItem( STORAGE_KEY, JSON.stringify( { accent } ) ); + new OsSettings( { apply: vi.fn() } as unknown as WallpaperLayer ).apply(); +} + +/** What a link resolves to: the inline write, else the palette's value. */ +function linkOf( token: string ): string { + return document.body.style.getPropertyValue( token ) || declared( token ); +} + +beforeEach( () => { + _resetAllSharedStoresForTests(); + installHooksStub(); + window.localStorage.clear(); + document.body.innerHTML = ''; + document.body.removeAttribute( 'style' ); + const shell = document.createElement( 'div' ); + shell.id = 'os-shell'; + document.body.appendChild( shell ); + return () => clearHooksStub(); +} ); + +describe( 'apply(): link color follows the accent', () => { + test.each( DEFAULT_ACCENTS.map( ( a ) => [ a.id, a.value ] ) )( + '%s (%s): links follow it and read at AA on every dark surface', + ( id, value ) => { + applyAccent( id ); + for ( const token of TOKENS ) { + const link = linkOf( token ); + for ( const surface of SURFACES ) { + expect( + contrast( link, surface ), + `${ token } ${ link } on ${ surface }`, + ).toBeGreaterThanOrEqual( 4.5 ); + } + // Pulse keeps the palette's Nebula; every other swatch + // must move the link off it. + if ( id !== 'pulse' && value !== declared( token ) ) { + expect( link, token ).not.toBe( declared( token ) ); + } + } + }, + ); + + test.each( [ 'pulse', 'nebula' ] )( + '%s leaves links at Nebula, #ec9bff', + ( id ) => { + applyAccent( id ); + for ( const token of TOKENS ) { + expect( linkOf( token ) ).toBe( '#ec9bff' ); + } + }, + ); + + test( 'a worn desktop theme keeps the link color it declares', () => { + document + .getElementById( 'os-shell' )! + .setAttribute( 'data-os-desktop-theme', 'legacy' ); + applyAccent( 'wp-blue' ); + for ( const token of TOKENS ) { + expect( document.body.style.getPropertyValue( token ) ).toBe( '' ); + } + } ); +} );