From 91e93e6887ecd777f0ac5fe3cd0dff4540be286b Mon Sep 17 00:00:00 2001 From: Nibu Date: Tue, 10 Nov 2026 01:18:45 +0000 Subject: [PATCH] scene: Dusk floor at the link bound with Day's pale-blue lift, so the dark link clears AA Dusk's floor moves from 0.317 to 0.4744 (the dark link's bound 0.4554 plus the 0.019 margin Day keeps over its text bound), and its lift from rose #eba0b9 (luma 0.463, below the new floor) to Day's #e2f4ff. The ridge dusk stops are shaped into the same band, since the unshaped twilight dipped to 0.336. skyBackdrop('dusk').darkest becomes 0.460352; Dusk text 4.60 -> 6.59, link 3.17 -> 4.54. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/ridge/skyColour.ts | 5 ++-- src/app/features/scene/skyBand.test.ts | 2 +- src/app/features/scene/skyBand.ts | 13 ++++++--- src/app/features/scene/skyForState.test.ts | 34 +++++++++++++++++----- src/app/features/scene/skyForState.ts | 27 +++++++++++++---- src/app/features/scene/skyState.ts | 2 +- src/app/features/scene/skyTheme.test.ts | 12 ++++---- src/app/features/scene/skyTheme.ts | 8 ++--- 8 files changed, 71 insertions(+), 32 deletions(-) diff --git a/src/app/features/scene/ridge/skyColour.ts b/src/app/features/scene/ridge/skyColour.ts index 3a5147a4..7917d89e 100644 --- a/src/app/features/scene/ridge/skyColour.ts +++ b/src/app/features/scene/ridge/skyColour.ts @@ -1,8 +1,7 @@ // The one place the sky's colours come from. Void's `src/sky/sun.js` (`light({ time, lat, lon, // cloud_cover, precipitation })`, PR #20) is the intended source; until it lands this returns a -// fixed twilight: lavender zenith, rose mid-sky, orange horizon. Its darkest blend stays above the dusk -// floor of ../skyBand.ts (the dark ink of Noonday Sky needs a backdrop of at least 0.2983 luminance), which -// skyForState.test.ts sweeps. Replace the body, keep the signature: the scene calls it once, at startup, +// fixed twilight: lavender zenith, rose mid-sky, orange horizon. These are the raw stops: the scene draws them +// shaped into the dusk band of ../skyBand.ts (`ridgeSkyFor`, skyForState.ts), whose floor the link of Noonday Sky needs. Replace the body, keep the signature: the scene calls it once, at startup, // and bakes the result. export type Srgb = readonly [number, number, number]; diff --git a/src/app/features/scene/skyBand.test.ts b/src/app/features/scene/skyBand.test.ts index 5963fb79..e38f676a 100644 --- a/src/app/features/scene/skyBand.test.ts +++ b/src/app/features/scene/skyBand.test.ts @@ -23,7 +23,7 @@ describe('shapeToBand', () => { it('lifts black to the tint scaled in, not to grey', () => { const c = shapeToBand([0, 0, 0], SKY_BAND.dusk); expect(near(luma(c), SKY_BAND.dusk.floor)).toBe(true); - expect(c[0]).toBeGreaterThan(c[2]); // the dusk tint is rose + expect(c[2]).toBeGreaterThan(c[0]); // the dusk tint is Day's pale blue }); it('leaves a sky already in the band as it is, and is continuous at the floor', () => { diff --git a/src/app/features/scene/skyBand.ts b/src/app/features/scene/skyBand.ts index ac311081..a74784b3 100644 --- a/src/app/features/scene/skyBand.ts +++ b/src/app/features/scene/skyBand.ts @@ -1,12 +1,14 @@ // The luminance band each fixed sky state's drawn sky is shaped into, so the state's theme ink // clears 4.5:1 on bare sky (WCAG relative luminance, the `luminance` of src/sky/skyTokens.js). // -// day, dusk Noonday Sky's dark text ink #342d25 (L 0.0274) needs a backdrop of at least 0.2983. +// day Noonday Sky's dark text ink #342d25 (L 0.0274) needs a backdrop of at least 0.2983. // The floor is that text bound (0.30 as drawn); there is no ceiling, dark ink on light is fine. +// dusk The same ink, and its link #2a4f27 (L 0.0623), which needs a backdrop of at least 0.4554. The floor is +// the link bound (0.4744 as drawn), so text clears 4.5:1 with room (6.6) and the link clears too. // night Midnight Sky's light text ink #aeccff (L 0.594) needs a backdrop of at most 0.0931. // The baked night sky (0.058 to 0.077) is kept; the ceiling is the bound. // -// Link ink is not part of the band: see skyTheme.ts. +// Night's link ink is not part of the band: see skyTheme.ts. // // The terrain sky is a physical scatter function, so it is shaped downstream, on its final colour: // `shapeToBand` here, and its GLSL twin (`SHAPE_SKY_GLSL`) in terrain/terrainShaders.ts, built from the @@ -56,7 +58,8 @@ export interface SkyBand { } /** - * The shaping targets, in linear luma. The floor sits 0.019 above the text bound (0.2983): the screen encoding is + * The shaping targets, in linear luma. Day's floor sits 0.019 above the text bound (0.2983), Dusk's 0.019 above the link + * bound (0.4554), which skyForState.test.ts derives from the ink and ties to these: 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. 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 @@ -64,7 +67,9 @@ export interface SkyBand { */ export const SKY_BAND: Record = { day: { floor: 0.317, ceiling: 1, lift: hexToLinear('#e2f4ff') }, - dusk: { floor: 0.317, ceiling: 1, lift: hexToLinear('#eba0b9') }, + // Dusk lifts toward Day's pale blue rather than a rose: mixing a third of the sky toward rose washed the twilight pink, and + // a rose lighter than the floor shifts hues by 47 to 72 degrees where Day's tint shifts them by 18 (measured, PR body). + dusk: { floor: 0.4744, ceiling: 1, lift: hexToLinear('#e2f4ff') }, 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 9d1dfd2d..a8199b1b 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -12,7 +12,7 @@ import { terrainDrawn, terrainSkyFor, } from './skyForState'; -import { displayedLuminance, SKY_BAND } from './skyBand'; +import { displayedLuminance, luma, shapeToBand, SKY_BAND } from './skyBand'; import type { FixedSkyState } from './skyBand'; import { setSkyClock, skyBackdrop } from './skyState'; import { view } from './terrain/terrainWorld'; @@ -24,14 +24,17 @@ const STATES = ['day', 'dusk', 'night'] as const; const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; /** - * The band each state's text ink needs, from the ink itself: dark ink needs a backdrop of at least - * AA * (L + 0.05) - 0.05, light ink at most (L + 0.05) / AA - 0.05. Day 0.2983, dusk 0.2983, night 0.0931. + * The band each state needs, from the ink itself: dark ink needs a backdrop of at least + * AA * (L + 0.05) - 0.05, light ink at most (L + 0.05) / AA - 0.05. Day 0.2983, night 0.0931; dusk is bound by its + * link (0.4554), the darker ink to clear, whose floor also holds the text. */ const needs = (state: FixedSkyState) => { - const l = luminance(SKY[skyThemeId(state)].tokens.Background.OnContainer); + const sky = SKY[skyThemeId(state)]; + const l = luminance(sky.tokens.Background.OnContainer); + const link = luminance(sky.link); return state === 'night' ? { floor: 0, ceiling: (l + 0.05) / AA - 0.05 } - : { floor: AA * (l + 0.05) - 0.05, ceiling: 1 }; + : { floor: AA * ((state === 'dusk' ? link : l) + 0.05) - 0.05, ceiling: 1 }; }; let search = ''; @@ -51,9 +54,26 @@ afterEach(() => { describe('the bands the text ink needs', () => { it('are the numbers the shaping was set from', () => { expect(needs('day').floor).toBeCloseTo(0.2983, 3); - expect(needs('dusk').floor).toBeCloseTo(0.2983, 3); + expect(needs('dusk').floor).toBeCloseTo(0.4554, 3); expect(needs('night').ceiling).toBeCloseTo(0.0931, 3); }); + + // The drawn floor and the lift are tied to those bounds, not to themselves: nudge either constant and this fails. + it('give Dusk a floor 0.019 above the link bound (as Day has above the text bound) and a lift Day shares', () => { + expect(SKY_BAND.dusk.floor).toBeCloseTo(needs('dusk').floor + 0.019, 4); + expect(SKY_BAND.day.floor).toBeCloseTo(needs('day').floor + 0.019, 3); + expect(SKY_BAND.dusk.lift).toEqual(SKY_BAND.day.lift); + expect(luma(SKY_BAND.dusk.lift)).toBeGreaterThan(SKY_BAND.dusk.floor); + }); + + it.each(['day', 'dusk'] as const)( + '%s: the darkest the shaping can show reads at the bound and under the floor', + (state) => { + const shown = displayedLuminance(shapeToBand([0, 0, 0], SKY_BAND[state]), 'low'); + expect(shown).toBeGreaterThanOrEqual(needs(state).floor); + expect(shown).toBeLessThan(SKY_BAND[state].floor); + } + ); }); describe('ridgeSkyFor', () => { @@ -68,7 +88,7 @@ describe('ridgeSkyFor', () => { it('draws a lavender, rose and orange twilight for dusk', () => { const d = ridgeSkyFor('dusk'); - expect(d).toEqual(skyColours()); + expect(d.ground).toEqual(skyColours().ground); const [t, m, h] = [d.top, d.mid, d.horizon]; expect(t[2]).toBeGreaterThan(t[1]); // lavender: blue over green expect(t[0]).toBeGreaterThan(t[1]); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index 7da17476..71e07ca6 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -10,11 +10,13 @@ import type { SkyColours, Srgb } from './ridge/skyColour'; import { skyColours } from './ridge/skyColour'; import { resolveSkyState } from './skyState'; import type { SkyState } from './skyState'; -import { displayedLuminance, SKY_BAND } from './skyBand'; +import { displayedLuminance, hexToLinear, shapeToBand, SKY_BAND } from './skyBand'; import type { SkyBand } from './skyBand'; import { luminance } from '../../../sky/skyTokens.js'; import { CAMERA } from './terrain/terrainWorld'; +const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; + const rgb = (hex: string): Srgb => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb; @@ -28,12 +30,27 @@ const fromGradient = (g: readonly string[], ground: Srgb): SkyColours => ({ /** Sun elevation in degrees for the terrain's dusk: just above the horizon, between the two bakes (as `?sun=` puts it). */ export const DUSK_SUN_ELEVATION = 2; -/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). */ +const toByte = (v: number): number => + Math.round(255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055)); + +/** A stop moved into the state's band, the way the terrain sky is (`shapeToBand`), through the sRGB curve the canvas draws with. */ +const inBand = (c: Srgb, band: SkyBand): Srgb => + shapeToBand(hexToLinear(rgbHex(c)), band).map(toByte) as unknown as Srgb; + +/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). Dusk's twilight stops are shaped into its band. */ export function ridgeSkyFor(state: SkyState): SkyColours { const { ground } = skyColours(); switch (resolveSkyState(state)) { - case 'dusk': - return skyColours(); + case 'dusk': { + const twilight = skyColours(); + const band = SKY_BAND.dusk; + return { + ...twilight, + top: inBand(twilight.top, band), + mid: inBand(twilight.mid, band), + horizon: inBand(twilight.horizon, band), + }; + } case 'day': return fromGradient(baked.noonday.gradient, ground); default: @@ -67,8 +84,6 @@ export function terrainSkyFor(state: SkyState): { } } -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; diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index 21117039..0b60d99b 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -131,7 +131,7 @@ export interface SkyBackdrop { // 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 }, + dusk: { darkest: 0.460352, lightest: 0.968744 }, night: { darkest: 0.05987, lightest: 0.081642 }, }; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 53cf2873..fd9f32dd 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -59,13 +59,13 @@ 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 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. + // in every state: 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 needs a backdrop of about 0.4554, which the dusk floor is set above. // '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'], + ['dusk', 'noonday-sky', '#342d25', '6.59', '#2a4f27', '4.54'], ['night', 'midnight-sky', '#aeccff', '4.89', '#8ad1bd', '4.53'], ]; @@ -84,10 +84,10 @@ describe('ink of the theme a state picks, on bare sky', () => { expect(ratio(ink, skyBackdrop(state))).toBeGreaterThanOrEqual(AA); }); - it('link clears AA on day and night and falls short on dusk', () => { + it('link clears AA in every state', () => { 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('dusk', 'noonday-sky')).toBeGreaterThanOrEqual(AA); expect(link('night', 'midnight-sky')).toBeGreaterThanOrEqual(AA); }); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 4ab3f43a..f8ab8b12 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -4,10 +4,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 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 +// bare sky: Day at least 0.2983 (dark ink), Night at most 0.0931 (light ink). Dusk's floor is higher, the bound of +// the dark link (0.4554), so its link clears as well. skyTheme.test.ts pins the measured text and link ratios. Night's +// link ink is not in the band, and 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'; -- 2.47.3