All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m16s
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 <noreply@anthropic.com>
220 lines
8.8 KiB
JavaScript
220 lines
8.8 KiB
JavaScript
// Midnight Sky and Noonday Sky: theme tokens as albedo x irradiance.
|
|
// Dependency-free apart from sun.js, so scripts/bake-sky.mjs and the tests run it under plain node.
|
|
//
|
|
// bakeSky('midnight' | 'noonday') -> { moment, elevation, sun, ambient_linear, horizon_hex,
|
|
// gradient: [hex x GRADIENT_STOPS], tokens, ... }
|
|
//
|
|
// The sky is lit by sun.js at a fixed moment (clear weather: a build-time bake has no feed).
|
|
// A surface is `albedo * irradiance`, where irradiance is the sun and sky on an upward-facing
|
|
// surface, rescaled to a per-role brightness `key` (the eye adapts to the scene; only the
|
|
// light's colour survives, not its absolute level). Everything marked EYEBALLED is tuned by
|
|
// looking and then held to WCAG AA by skyTokens.test.ts.
|
|
|
|
import { light } from './sun.js';
|
|
|
|
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
|
|
const mix3 = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
|
|
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 },
|
|
midnight: { time: '2026-09-22T00:00:00Z', lat: 45, lon: 0 },
|
|
};
|
|
|
|
// Lowest ambient a baked sky reports, linear RGB. sun.js's night sky is ~0.004, which is
|
|
// starlight-true but too dark to see by on a screen; this lifts it to a bluish moonlit-skyglow
|
|
// level. Daylight ambient is far above it, so the floor only ever touches the night.
|
|
export const AMBIENT_FLOOR = [0.018, 0.024, 0.04];
|
|
|
|
// Linear-RGB albedos of the things Yggdrasil is made of. EYEBALLED around measured ranges
|
|
// (dark bark 0.05-0.1, birch bark ~0.6, leaves ~0.05 red/0.15 green/0.03 blue, wet soil ~0.1).
|
|
export const ALBEDO = {
|
|
soil: [0.1, 0.065, 0.04],
|
|
bark: [0.075, 0.048, 0.03],
|
|
barkPale: [0.6, 0.57, 0.5], // birch and lichen
|
|
barkChar: [0.035, 0.026, 0.018],
|
|
leaf: [0.06, 0.2, 0.05],
|
|
leafPale: [0.3, 0.5, 0.16], // new growth
|
|
clay: [0.36, 0.1, 0.07],
|
|
ochre: [0.4, 0.28, 0.06],
|
|
};
|
|
|
|
// Irradiance on an upward surface, unit-luminance: only the light's colour is kept.
|
|
function chroma({ direct, diffuse, elevation }) {
|
|
const up = Math.max(0, Math.sin((elevation * Math.PI) / 180));
|
|
const e = diffuse.map((d, i) => d + direct[i] * up);
|
|
const y = luma(e);
|
|
return e.map((v) => v / y);
|
|
}
|
|
|
|
// sRGB transfer, linear <-> encoded.
|
|
const encode = (v) => (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055);
|
|
const decode = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
|
export const toHex = (lin) =>
|
|
`#${
|
|
lin
|
|
.map((v) =>
|
|
Math.round(clamp(encode(clamp(v, 0, 1)), 0, 1) * 255)
|
|
.toString(16)
|
|
.padStart(2, '0')
|
|
)
|
|
.join('')}`;
|
|
export const fromHex = (hex) =>
|
|
[1, 3, 5].map((i) => decode(parseInt(hex.slice(i, i + 2), 16) / 255));
|
|
export const luminance = (hex) => luma(fromHex(hex));
|
|
export const contrast = (a, b) => {
|
|
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
return (hi + 0.05) / (lo + 0.05);
|
|
};
|
|
|
|
// Per-theme brightness keys (target luminance of an albedo-1 surface). EYEBALLED.
|
|
const DARK = {
|
|
skyKey: 0.06, // zenith of the baked gradient
|
|
main: 0.85, // buttons and accents: lit pale albedos
|
|
container: 0.06, // tinted panels: lit mid albedos
|
|
text: 1.1,
|
|
steps: { Hover: 1.18, Active: 1.36, Line: 1.7 },
|
|
};
|
|
const LIGHT = {
|
|
skyKey: 0.55,
|
|
main: 0.55,
|
|
container: 1.0,
|
|
text: 1.0,
|
|
steps: { Hover: 0.93, Active: 0.86, Line: 0.76 },
|
|
};
|
|
|
|
// Pigments for other people's names (--mx-uc-1..8): flower and berry albedos. EYEBALLED hues.
|
|
const PIGMENTS = [
|
|
[0.1, 0.25, 0.7],
|
|
[0.45, 0.15, 0.6],
|
|
[0.1, 0.5, 0.3],
|
|
[0.7, 0.15, 0.3],
|
|
[0.8, 0.35, 0.08],
|
|
[0.1, 0.5, 0.55],
|
|
[0.3, 0.3, 0.8],
|
|
[0.45, 0.6, 0.12],
|
|
];
|
|
|
|
export function bakeSky(name) {
|
|
const dark = name === 'midnight';
|
|
const T = dark ? DARK : LIGHT;
|
|
const moment = MOMENTS[name];
|
|
const l = light(moment);
|
|
const c = chroma(l);
|
|
const lit = (albedo, key) => albedo.map((a, i) => a * c[i] * key);
|
|
const hex = (albedo, key) => toHex(lit(albedo, key));
|
|
|
|
// Sky gradient, top to bottom: zenith radiance ~ diffuse, horizon whitened by aerosol and
|
|
// warmed by whatever direct light is left. EYEBALLED mix.
|
|
const zenith = l.diffuse;
|
|
const grey = luma(zenith);
|
|
const horizon = zenith.map((z, i) => z * 0.6 + grey * 0.7 + l.direct[i] * 0.05);
|
|
const scale = T.skyKey / luma(zenith);
|
|
const gradient = Array.from({ length: GRADIENT_STOPS }, (_, k) => {
|
|
const t = (k / (GRADIENT_STOPS - 1)) ** 1.6; // dwell on the upper sky
|
|
return toHex(mix3(zenith, horizon, t).map((v) => v * scale));
|
|
});
|
|
|
|
const stepped = (albedo, key) =>
|
|
Object.fromEntries(Object.entries(T.steps).map(([n, s]) => [n, hex(albedo, key * s)]));
|
|
const PALE = ALBEDO.barkPale;
|
|
const tint = (a) => (dark ? a : mix3(a, PALE, 0.8)); // light containers are pale tints
|
|
const text = hex(dark ? PALE : ALBEDO.barkChar, T.text);
|
|
|
|
const panel = (albedo, key) => ({
|
|
Container: hex(albedo, key),
|
|
...Object.fromEntries(
|
|
Object.entries(stepped(albedo, key)).map(([n, v]) => [`Container${ n}`, v])
|
|
),
|
|
OnContainer: text,
|
|
});
|
|
const accent = (albedo) => {
|
|
const main = dark ? mix3(albedo, PALE, 0.35) : albedo;
|
|
// Label: whichever of a charred or a pale lit surface reads better. The button is then
|
|
// pushed away from it (brighter on dark, darker on light) until the pair clears AA.
|
|
const labels = [hex(ALBEDO.barkChar, 0.9), hex(PALE, 1.4)];
|
|
let key = T.main;
|
|
let mainHex = hex(main, key);
|
|
const onMain = labels.sort((a, b) => contrast(mainHex, b) - contrast(mainHex, a))[0];
|
|
for (let n = 0; n < 40 && contrast(mainHex, onMain) < AA; n += 1) {
|
|
key *= dark ? 1.04 : 0.96;
|
|
mainHex = hex(main, key);
|
|
}
|
|
return {
|
|
Main: mainHex,
|
|
...Object.fromEntries(Object.entries(stepped(main, key)).map(([n, v]) => [`Main${ n}`, v])),
|
|
OnMain: onMain,
|
|
Container: hex(tint(albedo), T.container),
|
|
...Object.fromEntries(
|
|
Object.entries(stepped(tint(albedo), T.container)).map(([n, v]) => [`Container${ n}`, v])
|
|
),
|
|
OnContainer: text,
|
|
};
|
|
};
|
|
|
|
// Depth reads as brightness: Background < Surface < SurfaceVariant on dark, the reverse on light.
|
|
const depth = dark ? [0.05, 0.12, 0.2] : [0.85, 1.15, 1.0];
|
|
const tokens = {
|
|
Background: panel(dark ? ALBEDO.soil : PALE, depth[0]),
|
|
Surface: panel(dark ? ALBEDO.bark : ALBEDO.barkPale, depth[1]),
|
|
SurfaceVariant: panel(dark ? ALBEDO.bark : ALBEDO.barkPale, depth[2]),
|
|
Primary: accent(dark ? ALBEDO.leafPale : ALBEDO.leaf),
|
|
Secondary: accent(dark ? ALBEDO.barkPale : ALBEDO.bark),
|
|
Success: accent(dark ? ALBEDO.leafPale : ALBEDO.leaf),
|
|
Warning: accent(ALBEDO.ochre),
|
|
Critical: accent(ALBEDO.clay),
|
|
};
|
|
|
|
// Chat bubbles, same lighting: other people's on bark, your own on leaf.
|
|
const bubbles = {
|
|
Other: hex(dark ? ALBEDO.bark : mix3(ALBEDO.barkPale, ALBEDO.bark, 0.0), dark ? 0.06 : 0.82),
|
|
OtherText: text,
|
|
Own: hex(dark ? ALBEDO.leaf : mix3(ALBEDO.leafPale, PALE, 0.55), dark ? 0.1 : 0.9),
|
|
OwnText: text,
|
|
};
|
|
|
|
// Names must clear AA on every panel a message can sit on: pushed away from them until they do.
|
|
const grounds = [
|
|
tokens.Background.Container,
|
|
tokens.Surface.Container,
|
|
tokens.SurfaceVariant.Container,
|
|
bubbles.Other,
|
|
bubbles.Own,
|
|
];
|
|
const readable = (h) => grounds.every((g) => contrast(h, g) >= AA);
|
|
const fit = (albedo, ok = () => true) => {
|
|
let key = 0.5;
|
|
let h = hex(albedo, key);
|
|
for (let n = 0; n < 80 && !(readable(h) && ok(h)); n += 1) {
|
|
key *= dark ? 1.05 : 0.95;
|
|
h = hex(albedo, key);
|
|
}
|
|
return h;
|
|
};
|
|
|
|
return {
|
|
moment,
|
|
elevation: l.elevation,
|
|
// Sun for consumers that light a scene themselves: direction in degrees (azimuth clockwise
|
|
// from north) and the direct light's colour at normal incidence (black while it is below the horizon).
|
|
sun: { elevation_deg: l.elevation, azimuth_deg: l.azimuth, color_linear: l.direct },
|
|
ambient_linear: l.diffuse.map((v, i) => Math.max(v, AMBIENT_FLOOR[i])),
|
|
horizon_hex: gradient[GRADIENT_STOPS - 1], // last stop: the horizon end of the gradient
|
|
gradient,
|
|
tokens,
|
|
bubbles,
|
|
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),
|
|
};
|
|
}
|