diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts new file mode 100644 index 00000000..71d35fd0 --- /dev/null +++ b/src/app/features/scene/skyState.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { baked } from '../../../sky/baked'; +import { AA, luminance } from '../../../sky/skyTokens.js'; +import { + SKY_STATES, + SKY_STATE_KEY, + skyBackdrop, + type SkyBackdrop, + type SkyState, +} from './skyState'; + +type Mod = typeof import('./skyState'); + +function fakeStorage(initial: Record = {}) { + const data = new Map(Object.entries(initial)); + return { + data, + getItem: (k: string) => data.get(k) ?? null, + setItem: (k: string, v: string) => { + data.set(k, v); + }, + }; +} +const fresh = (): Promise => { + vi.resetModules(); + return import('./skyState'); +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('sky state', () => { + let storage: ReturnType; + beforeEach(() => { + storage = fakeStorage(); + vi.stubGlobal('localStorage', storage); + }); + + it('defaults to auto when nothing is stored', async () => { + expect((await fresh()).getSkyState()).toBe('auto'); + }); + + it.each(['night', 'day', 'dusk', 'auto'] as const)('restores a stored %s', async (s) => { + storage.data.set(SKY_STATE_KEY, s); + expect((await fresh()).getSkyState()).toBe(s); + }); + + it.each(['', 'noon', 'NIGHT', '"night"', 'null'])( + 'falls back to auto on stored %j', + async (v) => { + storage.data.set(SKY_STATE_KEY, v); + expect((await fresh()).getSkyState()).toBe('auto'); + } + ); + + it('falls back to auto when storage throws', async () => { + vi.stubGlobal('localStorage', { + getItem: () => { + throw new Error('denied'); + }, + setItem: () => { + throw new Error('denied'); + }, + }); + const m = await fresh(); + expect(m.getSkyState()).toBe('auto'); + m.setSkyState('night'); + expect(m.getSkyState()).toBe('night'); + }); + + it('persists a set state under one key and reads it back after a reload', async () => { + (await fresh()).setSkyState('dusk'); + expect([...storage.data.entries()]).toEqual([[SKY_STATE_KEY, 'dusk']]); + expect((await fresh()).getSkyState()).toBe('dusk'); + }); + + it('ignores an invalid state', async () => { + const m = await fresh(); + m.setSkyState('night'); + m.setSkyState('noon' as SkyState); + expect(m.getSkyState()).toBe('night'); + expect(storage.data.get(SKY_STATE_KEY)).toBe('night'); + }); + + it('notifies subscribers on change only, and stops after unsubscribe', async () => { + const m = await fresh(); + const cb = vi.fn(); + const off = m.subscribe(cb); + m.setSkyState('day'); + m.setSkyState('day'); + expect(cb.mock.calls).toEqual([['day']]); + off(); + m.setSkyState('night'); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('tells a late subscriber nothing until the next change', async () => { + const m = await fresh(); + m.setSkyState('day'); + const cb = vi.fn(); + m.subscribe(cb); + expect(cb).not.toHaveBeenCalled(); + }); +}); + +describe('skyBackdrop', () => { + it('orders the fixed skies night < dusk < day by their lightest sky', () => { + expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest); + expect(skyBackdrop('dusk').lightest).toBeLessThan(skyBackdrop('day').lightest); + }); + + it('is the luminance of the baked gradients for day and night', () => { + const lum = (g: readonly string[]) => g.map(luminance); + expect(skyBackdrop('day').lightest).toBe(Math.max(...lum(baked.noonday.gradient))); + expect(skyBackdrop('night').darkest).toBe(Math.min(...lum(baked.midnight.gradient))); + }); + + it('bounds every fixed sky when auto', () => { + const a = skyBackdrop('auto'); + (['day', 'dusk', 'night'] as const).forEach((s) => { + expect(a.lightest).toBeGreaterThanOrEqual(skyBackdrop(s).lightest); + expect(a.darkest).toBeLessThanOrEqual(skyBackdrop(s).darkest); + }); + }); + + it('never has darkest above lightest', () => { + SKY_STATES.forEach((s) => + expect(skyBackdrop(s).darkest).toBeLessThanOrEqual(skyBackdrop(s).lightest) + ); + }); +}); + +// Login text and link, per theme, from the card variables in pages/auth/styles.css.ts (the +// fallback pair serves Light, Silver, Dark and Butter) and the sky themes' baked bubble text and +// link. Light ink is measured against the lightest sky it can sit on, dark ink against the darkest. +const inks = [ + { theme: 'Light/Silver/Dark/Butter', text: '#b7c7d9', link: '#99c7ff' }, + { theme: 'Midnight Sky', text: baked.midnight.bubbles.OtherText, link: baked.midnight.link }, + { theme: 'Noonday Sky', text: baked.noonday.bubbles.OtherText, link: baked.noonday.link }, +]; +// WCAG contrast of an ink against a backdrop of the given luminance, with the ink's polarity +// deciding which end of the sky is its worst case. +const ratio = (ink: string, b: SkyBackdrop) => { + const li = luminance(ink); + const bg = li > 0.18 ? b.lightest : b.darkest; + const [hi, lo] = li > bg ? [li, bg] : [bg, li]; + return (hi + 0.05) / (lo + 0.05); +}; + +describe('login ink against the sky backdrop', () => { + const rows = SKY_STATES.flatMap((state) => + inks.flatMap(({ theme, text, link }) => + ( + [ + ['text', text], + ['link', link], + ] as const + ).map(([role, ink]) => ({ state, theme, role, ratio: ratio(ink, skyBackdrop(state)) })) + ) + ); + + it('prints the measured ratios', () => { + // eslint-disable-next-line no-console + console.table(rows.map((r) => ({ ...r, ratio: r.ratio.toFixed(2), ok: r.ratio >= AA }))); + expect(rows).toHaveLength(SKY_STATES.length * inks.length * 2); + }); + + // Bare sky does not hold either ink in most states: the card behind the login form stays opaque + // (pages/auth/styles.css.ts) for that reason. These are the cells that clear AA; every other + // cell is asserted to fail, so a sky or theme change that fixes one turns it red until listed. + const clears = new Set([ + 'day|Noonday Sky|text', + 'day|Noonday Sky|link', + 'night|Light/Silver/Dark/Butter|text', + 'night|Light/Silver/Dark/Butter|link', + 'night|Midnight Sky|text', + ]); + describe.each(rows)('$theme $role in $state', (r) => { + const holds = clears.has(`${r.state}|${r.theme}|${r.role}`); + (holds ? it : it.fails)(holds ? 'is at least AA' : 'is below AA (known)', () => { + expect(r.ratio).toBeGreaterThanOrEqual(AA); + }); + }); +}); diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts new file mode 100644 index 00000000..60e0471f --- /dev/null +++ b/src/app/features/scene/skyState.ts @@ -0,0 +1,84 @@ +// Which sky the signed-out page is drawn against. The state is one value, kept in one +// localStorage key; whoever draws the sky (the scene, the title bar's control) reads it here, and +// `skyBackdrop` says how light or dark the sky behind the login text can be in each state, so the +// text colours can be checked against it. +import { baked } from '../../../sky/baked'; +import { luminance } from '../../../sky/skyTokens.js'; +import { skyColours } from './ridge/skyColour'; + +export type SkyState = 'auto' | 'day' | 'dusk' | 'night'; + +export const SKY_STATES: readonly SkyState[] = ['auto', 'day', 'dusk', 'night']; +export const SKY_STATE_KEY = 'ratatoskr.skyState'; + +const isSkyState = (v: unknown): v is SkyState => SKY_STATES.includes(v as SkyState); + +function load(): SkyState { + try { + const stored = localStorage.getItem(SKY_STATE_KEY); + return isSkyState(stored) ? stored : 'auto'; + } catch { + return 'auto'; + } +} + +let current: SkyState | undefined; +const listeners = new Set<(s: SkyState) => void>(); + +export const getSkyState = (): SkyState => { + current ??= load(); + return current; +}; + +export function setSkyState(s: SkyState): void { + if (!isSkyState(s)) return; + const changed = s !== getSkyState(); + current = s; + try { + localStorage.setItem(SKY_STATE_KEY, s); + } catch { + // Storage is refused (private mode, quota): the choice holds for this page load only. + } + if (changed) [...listeners].forEach((cb) => cb(s)); +} + +export function subscribe(cb: (s: SkyState) => void): () => void { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; +} + +/** + * Relative luminance (WCAG, 0..1) of the lightest and darkest sky the state can show behind the + * page. Light ink is worst against `lightest`, dark ink against `darkest`. 'auto' follows the + * clock, so it can show any of the three fixed skies and bounds all of them. + */ +export interface SkyBackdrop { + readonly darkest: number; + readonly lightest: number; +} + +const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; +const bound = (colours: readonly string[]): SkyBackdrop => { + const l = colours.map(luminance); + return { darkest: Math.min(...l), lightest: Math.max(...l) }; +}; + +const FIXED: Record, SkyBackdrop> = (() => { + const dusk = skyColours(); + return { + day: bound(baked.noonday.gradient), + dusk: bound([dusk.top, dusk.mid, dusk.horizon].map(hex)), + night: bound(baked.midnight.gradient), + }; +})(); + +export function skyBackdrop(state: SkyState): SkyBackdrop { + if (state !== 'auto') return FIXED[state]; + const all = Object.values(FIXED); + return { + darkest: Math.min(...all.map((b) => b.darkest)), + lightest: Math.max(...all.map((b) => b.lightest)), + }; +}