From 7bc28f9256c38d30a7cd516ce7e377ef3cd073f6 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 01:09:46 +0000 Subject: [PATCH 1/2] scene: a rebuild is for a different drawn sky; a URL-steered night sky stays within the Night backdrop Rebuild key: the sky options the scene is built from (terrainSkyFor / ridgeSkyFor), not the pair of choice and resolved state, so picking Day while the clock says day, or Auto to the state it resolves to, no longer tears down and rebuilds the scene. A URL steers only 'auto', so there the options differ and it still rebuilds. Night: the band ceiling 0.085 let a sky the page URL steers (?sun=, ?sky=) show up to 0.0890, lighter than the Night constant 0.081705 the text was checked against. The ceiling is now 0.077394, the luma at which one 8-bit step up reads the baked night's own lightest, and the constant (0.081642) covers the steered case; a test pins it. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 17 +++++++------ src/app/features/scene/index.ts | 28 ++++++++++++++++++---- src/app/features/scene/skyBand.ts | 6 +++-- src/app/features/scene/skyForState.test.ts | 22 ++++++++++++++++- src/app/features/scene/skyState.ts | 3 ++- 5 files changed, 58 insertions(+), 18 deletions(-) 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)]; -- 2.47.3 From 2c271c6ded945ba0395256ef3dc643185c76c65c Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 01:13:29 +0000 Subject: [PATCH 2/2] sky: Midnight Sky's link is fitted to clear AA on the lightest Night sky The dark theme's link was #609385 (luminance 0.2505), 2.28:1 on the lightest Night sky (0.081642). It is now brightened in its own hue until its luminance is at least AA * (0.081642 + 0.05) - 0.05 = 0.5424: #8ad1bd (0.5468, hue 163 degrees as before), 4.53:1 on the lightest Night sky, 5.43:1 on the darkest, 11.28:1 on the sign-in card (was 5.68). Only the link changes in baked.ts; the user colours are unchanged. NIGHT_SKY_LIGHTEST is the Night backdrop constant, and a test fails if the two drift. Dusk is untouched. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyTheme.test.ts | 19 ++++++++++++------- src/app/features/scene/skyTheme.ts | 8 ++++---- src/app/pages/auth/styles.css.ts | 2 +- src/sky/baked.ts | 2 +- src/sky/skyTokens.js | 15 +++++++++++---- 5 files changed, 29 insertions(+), 17 deletions(-) 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), }; } -- 2.47.3