scene: catch a state change during the first build; skyBackdrop is the union of what the scenes draw
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled

index.ts compares the current state with the built one after subscribing and
rebuilds if they differ. skyBackdrop unions the ridge sky and the terrain sky
(skyColour sampled over the sphere, from chooseSky(terrainSkyFor(state))), so
terrain dusk's sun glow and terrain day's darker zenith are inside the range.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nibu 2026-11-09 22:31:00 +00:00
commit edc6a9a8f1
6 changed files with 161 additions and 39 deletions

View file

@ -62,6 +62,20 @@ describe('startBackgroundScene follows the sky state', () => {
expect(second.dispose).toHaveBeenCalledOnce();
});
it('rebuilds for a change that landed while the first build was awaited', async () => {
const [a, b] = [fakeScene(), fakeScene()];
createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b);
const started = startBackgroundScene(config, caps, { canvas });
setSkyState('day');
const scene = await started;
await flush();
expect(createRidgeScene).toHaveBeenCalledTimes(2);
expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]);
expect(a.dispose).toHaveBeenCalledOnce();
scene?.dispose();
expect(b.dispose).toHaveBeenCalledOnce();
});
it('replays a login onto the rebuilt scene, but only if one happened', async () => {
const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()];
createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c);

View file

@ -64,30 +64,36 @@ export async function startBackgroundScene(
const tier = resolveTier(config, capabilities);
if (tier === 'off') return null;
const build = (state: SkyState) => create(config, { ...options, tier }, state);
const first = await build(getSkyState());
const state = getSkyState();
const first = await build(state);
if (!first || config?.style === 'raymarch') return first;
let inner: Scene | null = first;
let built = state;
let loggedIn = false;
let disposed = false;
let generation = 0;
const off = subscribe(async (state) => {
const rebuild = async (next: SkyState) => {
generation += 1;
const mine = generation;
built = next;
inner?.dispose();
inner = null;
const next = await build(state);
const scene = await build(next);
if (disposed || mine !== generation) {
next?.dispose();
scene?.dispose();
return;
}
inner = next;
if (!next) options.onInactive?.();
inner = scene;
if (!scene) options.onInactive?.();
else {
options.onActive?.();
if (loggedIn) next.login();
if (loggedIn) scene.login();
}
});
};
const off = subscribe(rebuild);
// A change that landed while the first build was awaited was missed by the subscription.
if (getSkyState() !== built) rebuild(getSkyState());
return {
login() {
loggedIn = true;

View file

@ -2,7 +2,14 @@ import { describe, expect, it } from 'vitest';
import { baked } from '../../../sky/baked';
import { luminance } from '../../../sky/skyTokens.js';
import { skyColours } from './ridge/skyColour';
import { DUSK_SUN_ELEVATION, ridgeSkyFor, terrainSkyFor } from './skyForState';
import { chooseSky, skyColour } from './terrain/terrainSky';
import {
DUSK_SUN_ELEVATION,
ridgeDrawn,
ridgeSkyFor,
terrainDrawn,
terrainSkyFor,
} from './skyForState';
import { skyBackdrop } from './skyState';
const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
@ -63,3 +70,49 @@ describe('terrainSkyFor', () => {
});
});
});
describe('the terrain sky lies inside skyBackdrop', () => {
// A grid offset from the one skyBackdrop samples, so a miss between its samples shows.
const sweep = (state: 'day' | 'dusk' | 'night') => {
const sky = chooseSky(terrainSkyFor(state));
const out: number[] = [];
for (let el = 1.3; el <= 90; el += 4.1) {
for (let az = 0.7; az < 360; az += 4.3) {
const e = (el * Math.PI) / 180;
const a = (az * Math.PI) / 180;
const c = skyColour(sky, [
Math.cos(e) * Math.sin(a),
Math.sin(e),
-Math.cos(e) * Math.cos(a),
]);
const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v)));
out.push(0.2126 * r + 0.7152 * g + 0.0722 * b);
}
}
return out;
};
it.each(['day', 'dusk', 'night'] as const)('for %s', (state) => {
const { darkest, lightest } = skyBackdrop(state);
const l = sweep(state);
expect(Math.min(...l)).toBeGreaterThanOrEqual(darkest - EPS);
expect(Math.max(...l)).toBeLessThanOrEqual(lightest + EPS);
});
it('and the terrain dusk reaches the sun glow the ridge dusk does not', () => {
expect(skyBackdrop('dusk').lightest).toBeGreaterThan(0.9);
expect(skyBackdrop('dusk').lightest).toBeGreaterThan(Math.max(...sweep('night')));
});
});
describe('both scenes of a state lie inside skyBackdrop', () => {
it.each(['day', 'dusk', 'night'] as const)('%s', (state) => {
const b = skyBackdrop(state);
const r = ridgeDrawn(state);
const t = terrainDrawn(state);
[r, t].forEach((x) => {
expect(x?.darkest).toBeGreaterThanOrEqual(b.darkest - EPS);
expect(x?.lightest).toBeLessThanOrEqual(b.lightest + EPS);
});
});
});

View file

@ -4,10 +4,12 @@
// `skyBackdrop` bounds, so the colours a scene paints lie inside the luminance range the login
// text was checked against.
import { baked } from '../../../sky/baked';
import type { SkyName } from './terrain/terrainSky';
import { chooseSky, skyColour } from './terrain/terrainSky';
import type { SkyName, Vec3 } from './terrain/terrainSky';
import type { SkyColours, Srgb } from './ridge/skyColour';
import { skyColours } from './ridge/skyColour';
import type { SkyState } from './skyState';
import { luminance } from '../../../sky/skyTokens.js';
const rgb = (hex: string): Srgb =>
[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb;
@ -50,3 +52,47 @@ export function terrainSkyFor(state: SkyState): { sky?: SkyName; sun?: { elevati
return {};
}
}
const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
/** Relative luminance (WCAG) of the darkest and lightest colour a scene can draw. */
export interface Range {
readonly darkest: number;
readonly lightest: number;
}
/** The ridge sky's range: its top, middle and horizon stops (the canvas blends between them). */
export function ridgeDrawn(state: SkyState): Range | undefined {
const sky = ridgeSkyFor(state === 'auto' ? 'dusk' : state);
if (!sky) return undefined;
const l = [sky.top, sky.mid, sky.horizon].map((c) => luminance(rgbHex(c)));
return { darkest: Math.min(...l), lightest: Math.max(...l) };
}
const SAMPLE_DEG = 3;
const linearLuminance = (c: Vec3) => {
const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v)));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
/**
* The terrain sky's range: `skyColour` (the CPU twin of its sky shader) over the whole upper sphere, which the camera
* sweeps as it turns, for the sky the scene picks for this state. Sampled every 3 degrees.
*/
export function terrainDrawn(state: SkyState): Range {
const sky = chooseSky(terrainSkyFor(state));
let darkest = Infinity;
let lightest = -Infinity;
for (let el = 0; el <= 90; el += SAMPLE_DEG) {
for (let az = 0; az < 360; az += SAMPLE_DEG) {
const e = (el * Math.PI) / 180;
const a = (az * Math.PI) / 180;
const l = linearLuminance(
skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)])
);
darkest = Math.min(darkest, l);
lightest = Math.max(lightest, l);
}
}
return { darkest, lightest };
}

View file

@ -105,15 +105,20 @@ describe('sky state', () => {
});
describe('skyBackdrop', () => {
it('orders the fixed skies night < dusk < day by their lightest sky', () => {
it('puts night below day and dusk, which spans from dark to the sun glow', () => {
expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('day').darkest);
expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest);
expect(skyBackdrop('dusk').lightest).toBeLessThan(skyBackdrop('day').lightest);
expect(skyBackdrop('dusk').darkest).toBeLessThan(skyBackdrop('day').darkest);
});
it('is the luminance of the baked gradients for day and night', () => {
it('holds 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)));
expect(skyBackdrop('day').lightest).toBeGreaterThanOrEqual(
Math.max(...lum(baked.noonday.gradient)) - 1e-9
);
expect(skyBackdrop('night').darkest).toBeLessThanOrEqual(
Math.min(...lum(baked.midnight.gradient)) + 1e-9
);
});
it('bounds every fixed sky when auto', () => {
@ -171,11 +176,9 @@ describe('login ink against the sky backdrop', () => {
});
// 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
// (pages/auth/styles.css.ts) for that reason. These are the cells that clear AA, with the sky range the scenes can draw; 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',

View file

@ -2,9 +2,7 @@
// 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';
import { ridgeDrawn, terrainDrawn } from './skyForState';
export type SkyState = 'auto' | 'day' | 'dusk' | 'night';
@ -59,26 +57,28 @@ export interface SkyBackdrop {
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) };
// The union of every sky a scene draws for the state: the ridge sky and the terrain sky, each from
// the function its scene uses. 'auto' follows the clock (and a URL), so it bounds all three fixed
// states. Computed on first use: the terrain range samples its sky.
const union = (ranges: readonly (SkyBackdrop | undefined)[]): SkyBackdrop => {
const r = ranges.filter((x): x is SkyBackdrop => !!x);
return {
darkest: Math.min(...r.map((x) => x.darkest)),
lightest: Math.max(...r.map((x) => x.lightest)),
};
};
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),
};
})();
const FIXED_STATES = ['day', 'dusk', 'night'] as const;
const cache = new Map<SkyState, SkyBackdrop>();
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)),
};
let b = cache.get(state);
if (!b) {
b =
state === 'auto'
? union(FIXED_STATES.map(skyBackdrop))
: union([ridgeDrawn(state), terrainDrawn(state)]);
cache.set(state, b);
}
return b;
}