diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 7971508b..5d0e6410 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -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 () => { diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 73ab8d5a..55966188 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -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 { + 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; diff --git a/src/app/features/scene/skyBand.ts b/src/app/features/scene/skyBand.ts index b6b39315..ac311081 100644 --- a/src/app/features/scene/skyBand.ts +++ b/src/app/features/scene/skyBand.ts @@ -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 = { 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') }, }; /** diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 2ad3b5f6..9d1dfd2d 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -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] : []) + ), }; }; diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index 666e83d6..21117039 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -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 = { 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)]; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index f6873466..39b74fdc 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -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; diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 52aff750..4ab3f43a 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -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'; diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 4fa42d74..b13bf868 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -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%). diff --git a/src/sky/baked.ts b/src/sky/baked.ts index 0393fdbf..73c93bd5 100644 --- a/src/sky/baked.ts +++ b/src/sky/baked.ts @@ -115,7 +115,7 @@ export const baked = { '#677dff', '#6a926a', ], - link: '#609385', + link: '#8ad1bd', }, noonday: { moment: { diff --git a/src/sky/skyTokens.js b/src/sky/skyTokens.js index c234de12..5152a4fb 100644 --- a/src/sky/skyTokens.js +++ b/src/sky/skyTokens.js @@ -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), }; }