scene: sky-state seam (auto/day/dusk/night) with skyBackdrop #43
2 changed files with 269 additions and 0 deletions
185
src/app/features/scene/skyState.test.ts
Normal file
185
src/app/features/scene/skyState.test.ts
Normal file
|
|
@ -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<string, string> = {}) {
|
||||
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<Mod> => {
|
||||
vi.resetModules();
|
||||
return import('./skyState');
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('sky state', () => {
|
||||
let storage: ReturnType<typeof fakeStorage>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
84
src/app/features/scene/skyState.ts
Normal file
84
src/app/features/scene/skyState.ts
Normal file
|
|
@ -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<Exclude<SkyState, 'auto'>, 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)),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue