scene: sky-state seam (auto/day/dusk/night) with skyBackdrop #43

Merged
void merged 1 commit from nibu/sky-state into dev 2026-11-09 22:08:00 +00:00 AGit
2 changed files with 269 additions and 0 deletions

View 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);
});
});
});

View 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)),
};
}