scene: rebuild only for a different drawn sky; URL-steered night sky within the Night backdrop (+ link readability numbers) #55

Merged
void merged 2 commits from void/sky-link-readability into dev 2026-11-10 01:32:04 +00:00
10 changed files with 87 additions and 35 deletions

View file

@ -45,14 +45,14 @@ describe('startBackgroundScene follows the sky state', () => {
scene?.dispose();
});
it('rebuilds when auto is replaced by the explicit state it already resolves to (a URL may steer only auto)', async () => {
const [a, b] = [fakeScene(), fakeScene()];
createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b);
it('does not rebuild when auto is replaced by the explicit state it already resolves to', async () => {
const a = fakeScene();
createRidgeScene.mockReturnValueOnce(a);
const scene = await startBackgroundScene(config, caps, { canvas });
setSkyState('night');
await flush();
expect(a.dispose).toHaveBeenCalledOnce();
expect(createRidgeScene).toHaveBeenCalledTimes(2);
expect(a.dispose).not.toHaveBeenCalled();
expect(createRidgeScene).toHaveBeenCalledOnce();
scene?.dispose();
});
@ -146,14 +146,13 @@ describe('startBackgroundScene follows the sky state', () => {
expect(first.dispose).toHaveBeenCalledOnce();
});
it('drops a scene whose rebuild finishes after dispose', async () => {
const second = fakeScene();
createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(second);
it('builds nothing for a rebuild that is disposed before it starts', async () => {
createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(fakeScene());
const scene = await startBackgroundScene(config, caps, { canvas });
setSkyState('day');
scene?.dispose();
await flush();
expect(second.dispose).toHaveBeenCalledOnce();
expect(createRidgeScene).toHaveBeenCalledOnce();
});
it('keeps only the latest of two quick changes', async () => {

View file

@ -1,6 +1,6 @@
import type { Scene, SceneOptions } from './Scene';
import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier';
import { getResolvedSkyState, getSkyState, subscribe } from './skyState';
import { getSkyState, subscribe } from './skyState';
import type { SkyState } from './skyState';
export type { Scene, SceneStats } from './Scene';
@ -44,6 +44,24 @@ async function create(
return createScene(options);
}
/**
* What a rebuild is for: the sky options the scene would be built from. A change of choice that draws the same sky
* (Day picked while the clock says day, 'auto' to the fixed state it resolves to) is not a rebuild; the page URL steers
* only 'auto' (`terrainSkyFor`), so there the two differ and the key says so.
*/
async function skyKey(
config: BackgroundSceneConfig | undefined,
state: SkyState
): Promise<string | null> {
try {
const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState');
return JSON.stringify(config?.style === 'terrain' ? terrainSkyFor(state) : ridgeSkyFor(state));
} catch {
// The chunk failed to load: no key, so every change rebuilds (and `create` reports the failure).
return null;
}
}
/**
* The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no
* canvas context is requested; otherwise the scene code loads on demand, so it stays out of the
@ -66,7 +84,7 @@ export async function startBackgroundScene(
if (tier === 'off') return null;
const build = (state: SkyState) => create(config, { ...options, tier }, state);
const state = getSkyState();
const key0 = `${state}:${getResolvedSkyState()}`;
const key0 = config?.style === 'raymarch' ? null : await skyKey(config, state);
const first = await build(state);
if (!first || config?.style === 'raymarch') return first;
@ -75,11 +93,11 @@ export async function startBackgroundScene(
let loggedIn = false;
let disposed = false;
let generation = 0;
// A rebuild is for a new choice or a new resolved sky ('auto' crossing a boundary), not for a repeat of either.
// A rebuild is for a different sky ('auto' crossing a boundary, a new choice), not for a repeat of the one drawn.
const rebuild = async () => {
const next = getSkyState();
const key = `${next}:${getResolvedSkyState()}`;
if (key === built) return;
const key = await skyKey(config, next);
if ((key !== null && key === built) || disposed) return;
generation += 1;
const mine = generation;
built = key;

View file

@ -58,12 +58,14 @@ export interface SkyBand {
/**
* The shaping targets, in linear luma. The floor sits 0.019 above the text bound (0.2983): the screen encoding is
* gamma 2.2, which reads about 0.006 darker through the sRGB curve at this level, and 8-bit rounding with the sky
* pass's dither at its worst (one step in every channel) takes about 0.007 more.
* pass's dither at its worst (one step in every channel) takes about 0.007 more. The night ceiling is lower than the text bound
* (0.0931) by more than that: 0.077394 is the luma at which one step up reads 0.08164, the lightest the baked night sky shows
* (skyState.ts), so a sky the page URL steers at night, scaled down to the ceiling, cannot show lighter than the baked one.
*/
export const SKY_BAND: Record<FixedSkyState, SkyBand> = {
day: { floor: 0.317, ceiling: 1, lift: hexToLinear('#e2f4ff') },
dusk: { floor: 0.317, ceiling: 1, lift: hexToLinear('#eba0b9') },
night: { floor: 0, ceiling: 0.085, lift: hexToLinear('#5a6a9a') },
night: { floor: 0, ceiling: 0.077394, lift: hexToLinear('#5a6a9a') },
};
/**

View file

@ -183,6 +183,14 @@ describe('terrainSkyFor', () => {
expect(night.high).toBeLessThanOrEqual(needs('night').ceiling);
});
it('keeps a URL-steered sky at night no lighter than the Night backdrop the text was checked against', () => {
setSkyClock(() => atStandardTime(0, 0));
['?sun=60', '?sun=10&az=40', '?sun=2&az=250', '?sky=noonday'].forEach((q) => {
search = q;
expect(terrainDrawn('auto').lightest).toBeLessThanOrEqual(skyBackdrop('night').lightest);
});
});
it('lets an explicit state beat the URL, as before auto had one', () => {
search = '?sun=30&az=120&sky=midnight';
expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day });
@ -280,12 +288,24 @@ describe('skyBackdrop holds the union of what the scenes draw', () => {
return { darkest: hunt(-1), lightest: hunt(1) };
};
// At night a sky the page URL steers (?sun= ?sky=) is shaped to the band's ceiling, so the lightest it can show is one
// 8-bit step above the ceiling's own grey: the Night constant must cover it too, or text is checked against a sky
// lighter than the one it sits on.
const steeredNight = displayedLuminance(
[SKY_BAND.night.ceiling, SKY_BAND.night.ceiling, SKY_BAND.night.ceiling],
'high'
);
const computed = (state: FixedSkyState) => {
const r = [ridgeDrawn(state), terrainDrawn(state)];
const ind = extremes(state);
return {
darkest: Math.min(...r.map((x) => x.darkest), ind.darkest),
lightest: Math.max(...r.map((x) => x.lightest), ind.lightest),
lightest: Math.max(
...r.map((x) => x.lightest),
ind.lightest,
...(state === 'night' ? [steeredNight] : [])
),
};
};

View file

@ -127,11 +127,12 @@ export interface SkyBackdrop {
// this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering
// tables with it; skyForState.test.ts fails when they drift from the computed union. The ridge sky counts
// over its whole range, the terrain sky over what its frozen camera reaches (TERRAIN_REACH_DEG, skyForState.ts).
// Night's lightest also covers a sky the page URL steers (shaped to the band ceiling, skyBand.ts).
// Bounds what the 8-bit screen shows too (one step each way for rounding and dither), rounded outward to six places.
const BACKDROPS: Record<FixedSkyState, SkyBackdrop> = {
day: { darkest: 0.461063, lightest: 0.901108 },
dusk: { darkest: 0.30619, lightest: 0.968744 },
night: { darkest: 0.05987, lightest: 0.081705 },
night: { darkest: 0.05987, lightest: 0.081642 },
};
export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)];

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { atStandardTime } from './standardTime.testkit';
import { baked } from '../../../sky/baked';
import { AA, contrast, luminance } from '../../../sky/skyTokens.js';
import { AA, NIGHT_SKY_LIGHTEST, contrast, luminance } from '../../../sky/skyTokens.js';
import { Editor } from '../../components/editor/Editor.css';
import { PageContent, PageNavContent } from '../../components/page/style.css';
import { CallStatus } from '../call-status/styles.css';
@ -59,13 +59,14 @@ describe('skyThemeId', () => {
describe('ink of the theme a state picks, on bare sky', () => {
// Per state: the picked theme, its text ink and link, and their WCAG ratios on the sky the scenes
// draw (skyBackdrop, the worst case over its band). Text clears 4.5 in every state. Link clears
// on day and not on dusk or night: the links are derived in src/sky/skyTokens.js, outside the band, and these
// numbers are recorded as they are measured (Night's #609385 would clear 4.5 at a luminance of about 0.53 or more).
// on day and night and not on dusk: the links are derived in src/sky/skyTokens.js, outside the band, and these
// numbers are recorded as they are measured. Midnight's link is brightened in its own hue until it clears the
// lightest Night sky (NIGHT_SKY_LIGHTEST); Dusk's dark link would need a backdrop of about 0.455, and the dusk floor is lower.
// 'auto' resolves from the clock to one of these three and has the same numbers.
const cells: [FixedSkyState, string, string, string, string, string][] = [
['day', 'noonday-sky', '#342d25', '6.60', '#2a4f27', '4.55'],
['dusk', 'noonday-sky', '#342d25', '4.60', '#2a4f27', '3.17'],
['night', 'midnight-sky', '#aeccff', '4.89', '#609385', '2.28'],
['night', 'midnight-sky', '#aeccff', '4.89', '#8ad1bd', '4.53'],
];
it.each(cells)(
@ -83,11 +84,15 @@ describe('ink of the theme a state picks, on bare sky', () => {
expect(ratio(ink, skyBackdrop(state))).toBeGreaterThanOrEqual(AA);
});
it('link clears AA on day (the terrain camera sees only the lighter sky) and falls short on dusk and night', () => {
it('link clears AA on day and night and falls short on dusk', () => {
const link = (s: FixedSkyState, id: keyof typeof SKY) => ratio(SKY[id].link, skyBackdrop(s));
expect(link('day', 'noonday-sky')).toBeGreaterThanOrEqual(AA);
expect(link('dusk', 'noonday-sky')).toBeLessThan(AA);
expect(link('night', 'midnight-sky')).toBeLessThan(AA);
expect(link('night', 'midnight-sky')).toBeGreaterThanOrEqual(AA);
});
it('keeps the lightest Night sky the link was fitted to equal to the Night backdrop (no drift)', () => {
expect(Math.abs(NIGHT_SKY_LIGHTEST - skyBackdrop('night').lightest)).toBeLessThan(1e-6);
});
it.each([
@ -109,7 +114,7 @@ describe('ink of the theme a state picks, on bare sky', () => {
describe('the plate, where text cannot rest on bare sky', () => {
// Each theme's plate: fill, ink, link and the WCAG ratios of ink and link on the fill.
const plates = [
['midnight-sky', baked.midnight, '12.17', '5.68'],
['midnight-sky', baked.midnight, '12.17', '11.28'],
['noonday-sky', baked.noonday, '6.70', '4.62'],
] as const;

View file

@ -5,10 +5,10 @@
// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Each state's
// drawn sky is shaped into a luminance band (skyBand.ts) in which that ink's TEXT clears 4.5:1 on
// bare sky: Day and Dusk at least 0.2983 (dark ink), Night at most 0.0931 (light ink). skyTheme.test.ts
// pins the measured text and link ratios. Link ink is not in the band: the Night link (#609385,
// 2.36:1) and the dark links on Day and Dusk fall short, because the themes' link colours are
// derived outside this code (src/sky/skyTokens.js); lightening the Night link to a luminance of
// about 0.53 or more would clear it. The plate (skyPlate.css.ts) is still in the shell, pending
// pins the measured text and link ratios. Link ink is not in the band: the Day and Dusk links are dark
// and fall short of 4.5 where the sky is below about 0.455 (Dusk: 3.17), because the themes' link colours are
// derived outside this code (src/sky/skyTokens.js). Midnight Sky's link is derived there against the lightest Night sky
// (4.53 on it, 5.43 on the darkest). The plate (skyPlate.css.ts) is still in the shell, pending
// its removal.
import type { FixedSkyState, SkyState } from './skyState';

View file

@ -60,7 +60,7 @@ export const AuthFooter = style({
// Noonday); the other themes (Light, Silver, Dark, Butter) take the fallback colours.
// WCAG ratios of text / Register link on the card, per theme:
// Light, Silver, Dark, Butter #b7c7d9 / #99c7ff on #0d1a26 10.21 / 10.03
// Midnight Sky #aeccff / #609385 on #0a090e 12.17 / 5.68
// Midnight Sky #aeccff / #8ad1bd on #0a090e 12.17 / 11.28
// Noonday Sky #342d25 / #2a4f27 on #b9b6af 6.70 / 4.62
// The fallback link is set here because those themes' own links (Light 2.49:1, Silver 2.49:1) are
// made for their own grounds, not this card; Dark and Butter already use the same hsl(213 100% 80%).

View file

@ -115,7 +115,7 @@ export const baked = {
'#677dff',
'#6a926a',
],
link: '#609385',
link: '#8ad1bd',
},
noonday: {
moment: {

View file

@ -19,6 +19,10 @@ const luma = (c) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
export const GRADIENT_STOPS = 7;
export const AA = 4.5; // WCAG 2.x AA, normal text
// The lightest the Night sky draws behind the page (src/app/features/scene/skyState.ts, `skyBackdrop('night')`; a test in
// skyTheme.test.ts fails if the two drift). Midnight Sky's link also sits on bare sky, so it must clear AA against this.
export const NIGHT_SKY_LIGHTEST = 0.081642;
// Where and when the theme is lit. Equinox, 45N on the prime meridian: noon sun at 45 degrees.
export const MOMENTS = {
noonday: { time: '2026-09-22T12:00:00Z', lat: 45, lon: 0 },
@ -186,10 +190,10 @@ export function bakeSky(name) {
bubbles.Own,
];
const readable = (h) => grounds.every((g) => contrast(h, g) >= AA);
const fit = (albedo) => {
const fit = (albedo, ok = () => true) => {
let key = 0.5;
let h = hex(albedo, key);
for (let n = 0; n < 80 && !readable(h); n += 1) {
for (let n = 0; n < 80 && !(readable(h) && ok(h)); n += 1) {
key *= dark ? 1.05 : 0.95;
h = hex(albedo, key);
}
@ -207,7 +211,10 @@ export function bakeSky(name) {
gradient,
tokens,
bubbles,
userColors: PIGMENTS.map(fit),
link: fit(dark ? ALBEDO.leafPale : ALBEDO.leaf),
userColors: PIGMENTS.map((p) => fit(p)),
// The dark link also has to clear AA on the bare Night sky: brightened, in its own hue, until it does.
link: dark
? fit(ALBEDO.leafPale, (h) => luminance(h) >= AA * (NIGHT_SKY_LIGHTEST + 0.05) - 0.05)
: fit(ALBEDO.leaf),
};
}