From af093fd9790630e63f99033bc6627fb96f9191fa Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 22:07:23 +0000 Subject: [PATCH] Ridge night band: ceiling 0.04 so the foldout header ink clears 4.5:1 (Nibu's change, preview) RIDGE_NIGHT_BAND = SKY_BAND.night with ceiling 0.04 (foldout headers wear L 0.386, need a backdrop <= 0.047); ridgeSkyFor night shapes its stops into it; terrain keeps SKY_BAND.night; BACKDROPS night darkest 0.05987 -> 0.039159. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 4 +-- .../features/scene/ridge/ridgeBake.test.ts | 6 +++- src/app/features/scene/skyBand.ts | 7 +++++ src/app/features/scene/skyForState.test.ts | 28 +++++++++++++++---- src/app/features/scene/skyForState.ts | 21 +++++++++++--- src/app/features/scene/skyState.ts | 2 +- 6 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index b6fcf2a7..a5fa30ac 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -48,7 +48,7 @@ describe('startBackgroundScene follows the sky state', () => { createRidgeScene.mockReturnValue(fakeScene()); const scene = await startBackgroundScene(config, caps, { canvas }); expect(createRidgeScene).toHaveBeenCalledOnce(); - expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]); + expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(hex(ridgeSkyFor('night').top)); scene?.dispose(); }); @@ -67,7 +67,7 @@ describe('startBackgroundScene follows the sky state', () => { setSkyState('night'); createRidgeScene.mockReturnValue(fakeScene()); const scene = await startBackgroundScene(config, caps, { canvas }); - expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]); + expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(hex(ridgeSkyFor('night').top)); scene?.dispose(); }); diff --git a/src/app/features/scene/ridge/ridgeBake.test.ts b/src/app/features/scene/ridge/ridgeBake.test.ts index 8ce91f41..1b38bc3c 100644 --- a/src/app/features/scene/ridge/ridgeBake.test.ts +++ b/src/app/features/scene/ridge/ridgeBake.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { BakeParams, bakeSlice, faceLight, layerPaint, sunDirection } from './ridgeBake'; -import { SKY_BAND } from '../skyBand'; +import { RIDGE_NIGHT_BAND, SKY_BAND } from '../skyBand'; import { ridgeSkyFor } from '../skyForState'; import { LACUNARITY, LAYERS, profile } from './ridgeWorld'; import { skyColours, SkyColours } from './skyColour'; @@ -357,6 +357,10 @@ describe('the state band: the theme ink clears 4.5:1 on a ridge as on bare sky', expect(range(SKY_BAND.night).hi).toBeLessThanOrEqual(SKY_BAND.night.ceiling); }); + it("ridge night: no ridge pixel is lighter than the ridge's own, lower ceiling", () => { + expect(range(RIDGE_NIGHT_BAND).hi).toBeLessThanOrEqual(RIDGE_NIGHT_BAND.ceiling); + }); + it('is the identity with no band, so nothing else changes', () => { expect(layerPaint(LAYERS[2], SKY).shift).toBeNull(); expect(range(undefined).lo).toBeLessThan(SKY_BAND.day.floor); diff --git a/src/app/features/scene/skyBand.ts b/src/app/features/scene/skyBand.ts index a74784b3..d8ab2a35 100644 --- a/src/app/features/scene/skyBand.ts +++ b/src/app/features/scene/skyBand.ts @@ -73,6 +73,13 @@ export const SKY_BAND: Record = { night: { floor: 0, ceiling: 0.077394, lift: hexToLinear('#5a6a9a') }, }; +/** + * The ridge sky's night band: the same night, with a lower ceiling. The foldout headers wear a dimmer ink than the + * page text (L 0.386 against 0.594), and that ink needs a backdrop of at most 0.047 for 4.5:1. The terrain sky keeps + * SKY_BAND.night. + */ +export const RIDGE_NIGHT_BAND: SkyBand = { ...SKY_BAND.night, ceiling: 0.04 }; + /** * Shape a linear sky colour into the band. Below the floor it mixes toward `lift` by exactly the amount * that lands on the floor (continuous at the floor, hue kept); above the ceiling it scales down to the diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 37e170cb..7feabe80 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, luma, shapeToBand, SKY_BAND } from './skyBand'; +import { displayedLuminance, luma, RIDGE_NIGHT_BAND, shapeToBand, SKY_BAND } from './skyBand'; import type { FixedSkyState } from './skyBand'; import { setSkyClock, skyBackdrop } from './skyState'; import { view } from './terrain/terrainWorld'; @@ -78,9 +78,22 @@ describe('the bands the text ink needs', () => { describe('ridgeSkyFor', () => { it('carries the state band, which the layers are shaped into', () => { - for (const state of ['day', 'dusk', 'night'] as const) { - expect(ridgeSkyFor(state).band).toEqual(SKY_BAND[state]); - } + expect(ridgeSkyFor('day').band).toEqual(SKY_BAND.day); + expect(ridgeSkyFor('dusk').band).toEqual(SKY_BAND.dusk); + expect(ridgeSkyFor('night').band).toBe(RIDGE_NIGHT_BAND); + expect(terrainSkyFor('night').band).toBe(SKY_BAND.night); + }); + + it("night's stops are shaped into the ridge's own ceiling, the hue kept", () => { + const night = ridgeSkyFor('night'); + const baseline = [baked.midnight.gradient[0], baked.midnight.gradient[6]].map((h) => + [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)) + ); + [night.top, night.horizon].forEach((stop, i) => { + expect(luminance(hex(stop))).toBeLessThanOrEqual(RIDGE_NIGHT_BAND.ceiling + 0.0005); + // The brightest channel is the same one: scaling down in linear light does not turn the hue. + expect(stop.indexOf(Math.max(...stop))).toBe(baseline[i].indexOf(Math.max(...baseline[i]))); + }); }); it('draws the gradient of the sky auto resolves to, never nothing', () => { @@ -109,8 +122,11 @@ describe('ridgeSkyFor', () => { const night = ridgeSkyFor('night'); expect(hex(day.top)).toBe(baked.noonday.gradient[0]); expect(hex(day.horizon)).toBe(baked.noonday.gradient[6]); - expect(hex(night.top)).toBe(baked.midnight.gradient[0]); - expect(hex(night.horizon)).toBe(baked.midnight.gradient[6]); + // Night's stops are the baked ones shaped into the ridge's lower ceiling (next test), so only darker or equal. + expect(luminance(hex(night.top))).toBeLessThanOrEqual(luminance(baked.midnight.gradient[0])); + expect(luminance(hex(night.horizon))).toBeLessThanOrEqual( + luminance(baked.midnight.gradient[6]) + ); }); it.each(STATES)('%s: every stop and blend lies in the band its text ink needs', (state) => { diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index a9551f65..d9bce3e1 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -10,7 +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, hexToLinear, shapeToBand, SKY_BAND } from './skyBand'; +import { + displayedLuminance, + hexToLinear, + RIDGE_NIGHT_BAND, + shapeToBand, + SKY_BAND, +} from './skyBand'; import type { SkyBand } from './skyBand'; import { luminance } from '../../../sky/skyTokens.js'; import { CAMERA } from './terrain/terrainWorld'; @@ -38,7 +44,7 @@ const toByte = (v: number): number => 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. */ +/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). Dusk's twilight stops and night's baked stops are shaped into their bands (night's is the ridge's own, lower one). */ export function ridgeSkyFor(state: SkyState): SkyColours { const { ground } = skyColours(); switch (resolveSkyState(state)) { @@ -55,8 +61,15 @@ export function ridgeSkyFor(state: SkyState): SkyColours { } case 'day': return fromGradient(baked.noonday.gradient, ground, SKY_BAND.day); - default: - return fromGradient(baked.midnight.gradient, ground, SKY_BAND.night); + default: { + const night = fromGradient(baked.midnight.gradient, ground, RIDGE_NIGHT_BAND); + return { + ...night, + top: inBand(night.top, RIDGE_NIGHT_BAND), + mid: inBand(night.mid, RIDGE_NIGHT_BAND), + horizon: inBand(night.horizon, RIDGE_NIGHT_BAND), + }; + } } } diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index 0b60d99b..6f7d0fb6 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -132,7 +132,7 @@ export interface SkyBackdrop { const BACKDROPS: Record = { day: { darkest: 0.461063, lightest: 0.901108 }, dusk: { darkest: 0.460352, lightest: 0.968744 }, - night: { darkest: 0.05987, lightest: 0.081642 }, + night: { darkest: 0.039159, lightest: 0.081642 }, }; export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)]; -- 2.47.3