scene: rebuild only for a different drawn sky; URL-steered night sky within the Night backdrop (+ link readability numbers) #55
10 changed files with 87 additions and 35 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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') },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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] : [])
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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%).
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export const baked = {
|
|||
'#677dff',
|
||||
'#6a926a',
|
||||
],
|
||||
link: '#609385',
|
||||
link: '#8ad1bd',
|
||||
},
|
||||
noonday: {
|
||||
moment: {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue