scene: Dusk floor at the link bound with Day's pale-blue lift (held for Meta's preview) #56

Merged
void merged 1 commit from nibu/dusk-sky-floor into dev 2026-11-10 17:03:36 +00:00
8 changed files with 71 additions and 32 deletions

View file

@ -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];

View file

@ -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', () => {

View file

@ -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<FixedSkyState, SkyBand> = {
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') },
};

View file

@ -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]);

View file

@ -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;

View file

@ -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<FixedSkyState, SkyBackdrop> = {
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 },
};

View file

@ -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);
});

View file

@ -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';