Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions assets/css/variables.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
/*
Expand Down Expand Up @@ -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 `<html>`
Expand Down
4 changes: 2 additions & 2 deletions docs/desktop-themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<body>`: 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 |
Expand Down
108 changes: 93 additions & 15 deletions src/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}. */
Expand Down Expand Up @@ -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 );
}
}
}

/**
Expand Down
64 changes: 64 additions & 0 deletions tests/vitest/accent-ink.test.ts
Original file line number Diff line number Diff line change
@@ -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 );
} );
} );
115 changes: 115 additions & 0 deletions tests/vitest/os-settings-accent-link-apply.test.ts
Original file line number Diff line number Diff line change
@@ -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( '' );
}
} );
} );
Loading