From bebf1552cb5d5404d2e3fce1af8e1e0273cdf258 Mon Sep 17 00:00:00 2001 From: Juan Lentino <74268474+juanlentino@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:33:40 -0400 Subject: [PATCH 1/2] Preferences: Pick the accent ink with more contrast on the fill accentWantsDarkInk compared relative luminance against 0.35. The crossover where Void and Starlight have equal contrast is about 0.184, so between the two the picker chose Starlight where Void reads better: Teal, Emerald, Amber and custom colours such as #ff0000 (3.90:1 with Starlight, 4.91:1 with Void). Pulse keeps Starlight as before. Co-Authored-By: Claude Opus 5.5 --- assets/css/variables.css | 5 +-- docs/desktop-themes.md | 2 +- src/settings/index.ts | 17 ++++----- tests/vitest/accent-ink.test.ts | 64 +++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 tests/vitest/accent-ink.test.ts diff --git a/assets/css/variables.css b/assets/css/variables.css index 9148ef588..253eb5945 100644 --- a/assets/css/variables.css +++ b/assets/css/variables.css @@ -539,8 +539,9 @@ 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; --os-ui-accent-strong: #ec9bff; diff --git a/docs/desktop-themes.md b/docs/desktop-themes.md index e6dcbaca7..68885bae2 100644 --- a/docs/desktop-themes.md +++ b/docs/desktop-themes.md @@ -416,7 +416,7 @@ kit's own stylesheets never hard-wire it to a decorative surface. | `--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-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 610f4ab7b..bd4b19015 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -70,16 +70,17 @@ import { /** * 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. + * 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 { const match = /^#?([0-9a-f]{6})$/i.exec( hex.trim() ); - if ( ! match ) { + if ( ! match || match[ 1 ].toLowerCase() === 'f252fc' ) { return false; } const linear = ( pair: string ): number => { @@ -91,7 +92,7 @@ export function accentWantsDarkInk( hex: string ): boolean { 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; + return luminance > 0.184; } /** Options for {@link OsSettings.update}. */ diff --git a/tests/vitest/accent-ink.test.ts b/tests/vitest/accent-ink.test.ts new file mode 100644 index 000000000..930ebdcae --- /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 ); + } ); +} ); From 7e29581eb01d32926d9f7867afe11f347cd7ab6f Mon Sep 17 00:00:00 2001 From: Juan Lentino <74268474+juanlentino@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:37:32 -0400 Subject: [PATCH 2/2] Accent: links follow the picked accent, lifted to read at AA The accent picker wrote --os-ui-accent, its ink and its dim, and left --os-link and --os-ui-accent-strong at the palette's Nebula, so links and accent-strong labels stayed pink whatever swatch was picked. apply() now writes both inline on : the accent itself when it reads at 4.5:1 on Astro, otherwise the accent mixed toward Starlight until it does. Pulse keeps the palette's Nebula, as it keeps its dim, and nothing is written while a desktop theme is worn, since the theme owns the surfaces the link sits on. Co-Authored-By: Claude Opus 5.5 --- assets/css/variables.css | 8 +- docs/desktop-themes.md | 2 +- src/settings/index.ts | 101 +++++++++++++-- .../os-settings-accent-link-apply.test.ts | 115 ++++++++++++++++++ 4 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 tests/vitest/os-settings-accent-link-apply.test.ts diff --git a/assets/css/variables.css b/assets/css/variables.css index 253eb5945..7deb6ac86 100644 --- a/assets/css/variables.css +++ b/assets/css/variables.css @@ -544,6 +544,11 @@ body.os-active { * 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); /* @@ -1047,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 68885bae2..35222b3da 100644 --- a/docs/desktop-themes.md +++ b/docs/desktop-themes.md @@ -415,7 +415,7 @@ 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-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 | diff --git a/src/settings/index.ts b/src/settings/index.ts index bd4b19015..98ce603ac 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -67,6 +67,24 @@ import { registerCustomImageIfPresent, } from './wallpaper-defs'; +/** 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 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 ]; + return ( + 0.2126 * linear( hexRgb.slice( 0, 2 ) ) + + 0.7152 * linear( hexRgb.slice( 2, 4 ) ) + + 0.0722 * linear( hexRgb.slice( 4, 6 ) ) + ); +} + /** * Whether text on a fill of this colour should be dark. * @@ -79,20 +97,50 @@ import { * palette's own Starlight ink. */ export function accentWantsDarkInk( hex: string ): boolean { - const match = /^#?([0-9a-f]{6})$/i.exec( hex.trim() ); - if ( ! match || match[ 1 ].toLowerCase() === 'f252fc' ) { + if ( /^#?f252fc$/i.test( hex.trim() ) ) { return false; } - 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 = - 0.2126 * linear( hexRgb.slice( 0, 2 ) ) + - 0.7152 * linear( hexRgb.slice( 2, 4 ) ) + - 0.0722 * linear( hexRgb.slice( 4, 6 ) ); - return luminance > 0.184; + 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}. */ @@ -452,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/os-settings-accent-link-apply.test.ts b/tests/vitest/os-settings-accent-link-apply.test.ts new file mode 100644 index 000000000..b7a73e53f --- /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( '' ); + } + } ); +} );