sky: Midnight Sky and Noonday Sky baked from sun.js (albedo x irradiance)
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled

Two themes replace the navy GameCult theme. Every colour is a bark, leaf, soil
or pigment albedo times the irradiance sun.js gives for a fixed moment (equinox
noon and midnight, 45N). scripts/bake-sky.mjs writes src/sky/baked.ts: the
terrain-free sky gradient used as the fallback background, folds tokens for
panels and buttons, chat bubble colours, link and name colours.
skyTokens.test.ts holds every text pairing to WCAG AA and fails if baked.ts is
stale.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Void 2026-11-06 20:57:31 +00:00
commit 7b4aa2f184
11 changed files with 573 additions and 131 deletions

View file

@ -19,6 +19,7 @@
"prepare": "husky install",
"commit": "git-cz",
"bump": "node scripts/update-version.js",
"bake:sky": "node scripts/bake-sky.mjs && prettier --write src/sky/baked.ts",
"test": "vitest run"
},
"lint-staged": {

13
scripts/bake-sky.mjs Normal file
View file

@ -0,0 +1,13 @@
// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts.
// Run `npm run bake:sky` after changing sun.js, skyTokens.js or a moment.
import { writeFileSync } from 'node:fs';
import { bakeSky } from '../src/sky/skyTokens.js';
const out = new URL('../src/sky/baked.ts', import.meta.url);
const baked = { midnight: bakeSky('midnight'), noonday: bakeSky('noonday') };
writeFileSync(
out,
`// Generated by scripts/bake-sky.mjs from src/sky/sun.js. Do not edit.\n` +
`/* eslint-disable */\n` +
`export const baked = ${JSON.stringify(baked, null, 2)} as const;\n`
);

View file

@ -1,8 +1,15 @@
import { lightTheme } from 'folds';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
import { butterTheme, darkTheme, gamecultTheme, silverTheme } from '../../colors.css';
import {
butterTheme,
darkTheme,
midnightSkyTheme,
noondaySkyTheme,
silverTheme,
} from '../../colors.css';
import '../../gamecult.css';
import '../../sky.css';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
@ -39,14 +46,23 @@ export const ButterTheme: Theme = {
classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'],
};
export const GameCultTheme: Theme = {
id: 'gamecult-theme',
export const MidnightSkyTheme: Theme = {
id: 'midnight-sky',
kind: ThemeKind.Dark,
classNames: ['gamecult-theme', gamecultTheme, onDarkFontWeight, 'prism-dark'],
classNames: ['gamecult-theme', 'midnight-sky', midnightSkyTheme, onDarkFontWeight, 'prism-dark'],
};
export const NoondaySkyTheme: Theme = {
id: 'noonday-sky',
kind: ThemeKind.Light,
classNames: ['gamecult-theme', 'noonday-sky', noondaySkyTheme, onLightFontWeight, 'prism-light'],
};
export const useThemes = (): Theme[] => {
const themes: Theme[] = useMemo(() => [GameCultTheme, LightTheme, SilverTheme, DarkTheme, ButterTheme], []);
const themes: Theme[] = useMemo(
() => [MidnightSkyTheme, NoondaySkyTheme, LightTheme, SilverTheme, DarkTheme, ButterTheme],
[]
);
return themes;
};
@ -54,7 +70,8 @@ export const useThemes = (): Theme[] => {
export const useThemeNames = (): Record<string, string> =>
useMemo(
() => ({
[GameCultTheme.id]: 'GameCult',
[MidnightSkyTheme.id]: 'Midnight Sky',
[NoondaySkyTheme.id]: 'Noonday Sky',
[LightTheme.id]: 'Light',
[SilverTheme.id]: 'Silver',
[DarkTheme.id]: 'Dark',
@ -92,15 +109,15 @@ export const useActiveTheme = (): Theme => {
const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId');
if (!systemTheme) {
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? GameCultTheme;
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? MidnightSkyTheme;
return selectedTheme;
}
const selectedTheme =
systemThemeKind === ThemeKind.Dark
? themes.find((theme) => theme.id === darkThemeId) ?? GameCultTheme
: themes.find((theme) => theme.id === lightThemeId) ?? GameCultTheme;
? themes.find((theme) => theme.id === darkThemeId) ?? MidnightSkyTheme
: themes.find((theme) => theme.id === lightThemeId) ?? NoondaySkyTheme;
return selectedTheme;
};

View file

@ -1,7 +1,7 @@
import React, { ReactNode, useEffect } from 'react';
import { configClass, varsClass } from 'folds';
import {
GameCultTheme,
MidnightSkyTheme,
ThemeContextProvider,
useActiveTheme,
} from '../hooks/useTheme';
@ -12,8 +12,8 @@ export function UnAuthRouteThemeManager() {
useEffect(() => {
document.body.className = '';
document.body.classList.add(configClass, varsClass);
// The GameCult identity is single-theme: signed-out pages ignore the system scheme.
document.body.classList.add(...GameCultTheme.classNames);
// Signed-out pages are always Midnight Sky and ignore the system scheme.
document.body.classList.add(...MidnightSkyTheme.classNames);
}, []);
return null;

View file

@ -56,7 +56,7 @@ export interface Settings {
}
const defaultSettings: Settings = {
themeId: 'gamecult-theme',
themeId: 'midnight-sky',
useSystemTheme: false,
lightThemeId: undefined,
darkThemeId: undefined,

View file

@ -1,5 +1,6 @@
import { createTheme } from '@vanilla-extract/css';
import { color } from 'folds';
import { baked } from './sky/baked';
export const silverTheme = createTheme(color, {
Background: {
@ -240,91 +241,22 @@ export const butterTheme = createTheme(color, {
// GameCult identity (gamecult-site quartz.config.ts): single dark theme on #07111a,
// orange #ff8a2a accent, sky #59b7ff links. The brand defines no status colours, so
// Success/Warning/Critical are invented here and kept distinct from the orange accent.
export const gamecultTheme = createTheme(color, {
Background: {
Container: '#07111a',
ContainerHover: '#0d1a26',
ContainerActive: '#122130',
ContainerLine: '#16212c',
OnContainer: '#b7c7d9',
},
Surface: {
Container: '#09141f',
ContainerHover: '#0f1c29',
ContainerActive: '#152433',
ContainerLine: '#16212c',
OnContainer: '#b7c7d9',
},
SurfaceVariant: {
Container: '#0d1a26',
ContainerHover: '#122130',
ContainerActive: '#16273a',
ContainerLine: '#1b2a38',
OnContainer: '#b7c7d9',
},
Primary: {
Main: '#ff8a2a',
MainHover: '#ff9640',
MainActive: '#ffa155',
MainLine: '#ffab66',
OnMain: '#2a1300',
Container: '#2b1a0e',
ContainerHover: '#35200f',
ContainerActive: '#402611',
ContainerLine: '#4d2d13',
OnContainer: '#ffd3ab',
},
Secondary: {
Main: '#eef5ff',
MainHover: '#dbe7f6',
MainActive: '#cddced',
MainLine: '#bfd0e4',
OnMain: '#07111a',
Container: '#16212c',
ContainerHover: '#1c2a38',
ContainerActive: '#233342',
ContainerLine: '#2a3b4c',
OnContainer: '#eef5ff',
},
Success: {
Main: '#5fd0a4',
MainHover: '#72d8b0',
MainActive: '#84deba',
MainLine: '#93e3c2',
OnMain: '#042014',
Container: '#0c2a21',
ContainerHover: '#10342a',
ContainerActive: '#143e32',
ContainerLine: '#19493a',
OnContainer: '#c4f1de',
},
Warning: {
Main: '#f2d04b',
MainHover: '#f5d963',
MainActive: '#f7e078',
MainLine: '#f9e68b',
OnMain: '#2a2100',
Container: '#2a2410',
ContainerHover: '#352d13',
ContainerActive: '#403616',
ContainerLine: '#4d411a',
OnContainer: '#f8ebb0',
},
Critical: {
Main: '#ff7a90',
MainHover: '#ff8fa1',
MainActive: '#ffa0af',
MainLine: '#ffb0bc',
OnMain: '#3a0710',
Container: '#2e1118',
ContainerHover: '#391520',
ContainerActive: '#441a27',
ContainerLine: '#51202e',
OnContainer: '#ffd0d8',
},
// Midnight Sky and Noonday Sky: every colour is albedo x irradiance from src/sky/sun.js,
// baked by scripts/bake-sky.mjs.
export const midnightSkyTheme = createTheme(color, {
...baked.midnight.tokens,
Other: {
FocusRing: 'rgba(89, 183, 255, 0.55)',
FocusRing: 'rgba(132, 200, 181, 0.6)',
Shadow: 'rgba(0, 0, 0, 0.6)',
Overlay: 'rgba(3, 7, 13, 0.8)',
Overlay: 'rgba(3, 4, 10, 0.8)',
},
});
export const noondaySkyTheme = createTheme(color, {
...baked.noonday.tokens,
Other: {
FocusRing: 'rgba(43, 80, 40, 0.6)',
Shadow: 'rgba(40, 30, 20, 0.25)',
Overlay: 'rgba(20, 24, 30, 0.5)',
},
});

View file

@ -136,44 +136,11 @@ li p::before {
display: inline-block;
}
/* GameCult identity: Ubuntu 300 prose, Montserrat thin titles, IBM Plex Mono labels. */
/* GameCult identity (type only; colour is sky.css.ts): Ubuntu 300 prose, Montserrat thin titles, IBM Plex Mono labels. */
.gamecult-theme {
--tc-link: #59b7ff;
--mx-uc-1: hsl(208, 100%, 72%);
--mx-uc-2: hsl(280, 100%, 78%);
--mx-uc-3: hsl(163, 70%, 65%);
--mx-uc-4: hsl(343, 100%, 76%);
--mx-uc-5: hsl(28, 100%, 66%);
--mx-uc-6: hsl(181, 70%, 62%);
--mx-uc-7: hsl(243, 100%, 80%);
--mx-uc-8: hsl(94, 70%, 70%);
--font-secondary: 'Ubuntu', var(--font-emoji), sans-serif;
--font-title: 'Montserrat', 'Ubuntu', sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
--gamecult-rule: rgba(148, 163, 184, 0.22);
--gamecult-wash: 0.5;
background-color: #07111a;
background-image: radial-gradient(
circle at 78% 14%,
rgb(255 138 42 / calc(0.18 * var(--gamecult-wash))),
transparent 18%
),
radial-gradient(
circle at 18% 10%,
rgb(109 96 255 / calc(0.16 * var(--gamecult-wash))),
transparent 24%
),
radial-gradient(
circle at 52% 0%,
rgb(89 183 255 / calc(0.14 * var(--gamecult-wash))),
transparent 28%
),
linear-gradient(180deg, #03070d 0%, #07111a 44%, #09141f 100%);
background-attachment: fixed;
color-scheme: dark;
font-weight: 300;
}

29
src/sky.css.ts Normal file
View file

@ -0,0 +1,29 @@
import { globalStyle } from '@vanilla-extract/css';
import { baked } from './sky/baked';
// Fallback background and the colours folds has no token for. Everything is read from
// the bake (albedo x irradiance via sun.js); nothing here chooses a colour.
const skies = [
{ scope: 'body.midnight-sky', sky: baked.midnight, scheme: 'dark' },
{ scope: 'body.noonday-sky', sky: baked.noonday, scheme: 'light' },
] as const;
skies.forEach(({ scope, sky, scheme }) => {
const stops = sky.gradient.map(
(c, i) => `${c} ${Math.round((i / (sky.gradient.length - 1)) * 100)}%`
);
globalStyle(scope, {
colorScheme: scheme,
backgroundColor: sky.gradient[0],
backgroundImage: `linear-gradient(180deg, ${stops.join(', ')})`,
backgroundAttachment: 'fixed',
vars: {
'--tc-link': sky.link,
...Object.fromEntries(sky.userColors.map((c, i) => [`--mx-uc-${i + 1}`, c])),
'--sky-bubble': sky.bubbles.Other,
'--sky-bubble-text': sky.bubbles.OtherText,
'--sky-bubble-own': sky.bubbles.Own,
'--sky-bubble-own-text': sky.bubbles.OwnText,
},
});
});

222
src/sky/baked.ts Normal file
View file

@ -0,0 +1,222 @@
// Generated by scripts/bake-sky.mjs from src/sky/sun.js. Do not edit.
/* eslint-disable */
export const baked = {
midnight: {
moment: {
time: '2026-09-22T00:00:00Z',
lat: 45,
lon: 0,
},
elevation: -44.579021600011224,
gradient: ['#37446a', '#38456a', '#3b4669', '#3d4868', '#414a67', '#444c66', '#484e64'],
tokens: {
Background: {
Container: '#0b0a0f',
ContainerHover: '#0c0c11',
ContainerActive: '#0e0e13',
ContainerLine: '#111016',
OnContainer: '#aeccff',
},
Surface: {
Container: '#111117',
ContainerHover: '#14131a',
ContainerActive: '#16151c',
ContainerLine: '#191920',
OnContainer: '#aeccff',
},
SurfaceVariant: {
Container: '#191820',
ContainerHover: '#1c1b23',
ContainerActive: '#1e1d26',
ContainerLine: '#22222b',
OnContainer: '#aeccff',
},
Primary: {
Main: '#81afc7',
MainHover: '#8cbdd6',
MainActive: '#95c9e4',
MainLine: '#a5defc',
OnMain: '#272938',
Container: '#1c2f2a',
ContainerHover: '#1f342e',
ContainerActive: '#223832',
ContainerLine: '#263f38',
OnContainer: '#aeccff',
},
Secondary: {
Main: '#9bb6ff',
MainHover: '#a7c4ff',
MainActive: '#b2d1ff',
MainLine: '#c5e6ff',
OnMain: '#272938',
Container: '#2a334c',
ContainerHover: '#2e3852',
ContainerActive: '#323c58',
ContainerLine: '#384362',
OnContainer: '#aeccff',
},
Success: {
Main: '#81afc7',
MainHover: '#8cbdd6',
MainActive: '#95c9e4',
MainLine: '#a5defc',
OnMain: '#272938',
Container: '#1c2f2a',
ContainerHover: '#1f342e',
ContainerActive: '#223832',
ContainerLine: '#263f38',
OnContainer: '#aeccff',
},
Warning: {
Main: '#8b98b1',
MainHover: '#96a4be',
MainActive: '#a0aecb',
MainLine: '#b0c1e0',
OnMain: '#272938',
Container: '#212217',
ContainerHover: '#25261a',
ContainerActive: '#28291c',
ContainerLine: '#2d2e20',
OnContainer: '#aeccff',
},
Critical: {
Main: '#918ac0',
MainHover: '#9c95cf',
MainActive: '#a79fdc',
MainLine: '#b9aff3',
OnMain: '#272938',
Container: '#1f111a',
ContainerHover: '#22141c',
ContainerActive: '#25161f',
ContainerLine: '#2a1923',
OnContainer: '#aeccff',
},
},
bubbles: {
Other: '#0a090e',
OtherText: '#aeccff',
Own: '#0d261d',
OwnText: '#aeccff',
},
userColors: [
'#4683ff',
'#986fff',
'#3893b1',
'#b26adc',
'#9f8365',
'#358dde',
'#677dff',
'#6a926a',
],
link: '#609385',
},
noonday: {
moment: {
time: '2026-09-22T12:00:00Z',
lat: 45,
lon: 0,
},
elevation: 45.16994813502928,
gradient: ['#95c6ff', '#9ac9ff', '#a5cfff', '#b3d6ff', '#c2dfff', '#d2e9ff', '#e2f4ff'],
tokens: {
Background: {
Container: '#bcb9b2',
ContainerHover: '#b6b3ac',
ContainerActive: '#b0ada6',
ContainerLine: '#a6a39d',
OnContainer: '#342d25',
},
Surface: {
Container: '#d7d4cb',
ContainerHover: '#d0cdc5',
ContainerActive: '#c9c6be',
ContainerLine: '#bebbb4',
OnContainer: '#342d25',
},
SurfaceVariant: {
Container: '#cac7bf',
ContainerHover: '#c4c0b9',
ContainerActive: '#bdbab2',
ContainerLine: '#b3b0a9',
OnContainer: '#342d25',
},
Primary: {
Main: '#335d2f',
MainHover: '#315a2d',
MainActive: '#2f572b',
MainLine: '#2c5229',
OnMain: '#ebe7de',
Container: '#b9bbaf',
ContainerHover: '#b3b5a9',
ContainerActive: '#adafa3',
ContainerLine: '#a4a59a',
OnContainer: '#342d25',
},
Secondary: {
Main: '#392d23',
MainHover: '#372b22',
MainActive: '#352a20',
MainLine: '#31271e',
OnMain: '#ebe7de',
Container: '#bab6ae',
ContainerHover: '#b4b0a8',
ContainerActive: '#adaaa2',
ContainerLine: '#a4a09a',
OnContainer: '#342d25',
},
Success: {
Main: '#335d2f',
MainHover: '#315a2d',
MainActive: '#2f572b',
MainLine: '#2c5229',
OnMain: '#ebe7de',
Container: '#b9bbaf',
ContainerHover: '#b3b5a9',
ContainerActive: '#adafa3',
ContainerLine: '#a4a59a',
OnContainer: '#342d25',
},
Warning: {
Main: '#776530',
MainHover: '#73622e',
MainActive: '#6f5e2c',
MainLine: '#695929',
OnMain: '#ebe7de',
Container: '#c4bdaf',
ContainerHover: '#beb7a9',
ContainerActive: '#b7b1a4',
ContainerLine: '#ada79b',
OnContainer: '#342d25',
},
Critical: {
Main: '#7a4238',
MainHover: '#764036',
MainActive: '#723d34',
MainLine: '#6c3a31',
OnMain: '#ebe7de',
Container: '#c3b7af',
ContainerHover: '#bdb2aa',
ContainerActive: '#b6aba4',
ContainerLine: '#aca29b',
OnContainer: '#342d25',
},
},
bubbles: {
Other: '#b9b6af',
OtherText: '#342d25',
Own: '#acb99a',
OwnText: '#342d25',
},
userColors: [
'#2b4675',
'#5d366d',
'#214e3e',
'#6d334a',
'#5f401d',
'#204c52',
'#41426c',
'#3f4a20',
],
link: '#2a4f27',
},
} as const;

202
src/sky/skyTokens.js Normal file
View file

@ -0,0 +1,202 @@
// 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, 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
// 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 },
};
// 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) => {
let key = 0.5;
let h = hex(albedo, key);
for (let n = 0; n < 80 && !readable(h); n += 1) {
key *= dark ? 1.05 : 0.95;
h = hex(albedo, key);
}
return h;
};
return {
moment,
elevation: l.elevation,
gradient,
tokens,
bubbles,
userColors: PIGMENTS.map(fit),
link: fit(dark ? ALBEDO.leafPale : ALBEDO.leaf),
};
}

59
src/sky/skyTokens.test.ts Normal file
View file

@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { AA, bakeSky, contrast } from './skyTokens.js';
import { baked } from './baked';
const names = ['midnight', 'noonday'] as const;
const roles = ['Primary', 'Secondary', 'Success', 'Warning', 'Critical'] as const;
const states = ['', 'Hover', 'Active'];
describe.each(names)('%s sky', (name) => {
const sky = baked[name] as any;
const t = sky.tokens;
it('is what scripts/bake-sky.mjs would write now (run npm run bake:sky if not)', () => {
expect(JSON.parse(JSON.stringify(bakeSky(name)))).toEqual(JSON.parse(JSON.stringify(sky)));
});
it('is lit by the sun on the side of the horizon its name says', () => {
expect(name === 'noonday' ? sky.elevation : -sky.elevation).toBeGreaterThan(40);
});
it('clears WCAG AA for text on every panel state', () => {
['Background', 'Surface', 'SurfaceVariant'].forEach((p) =>
states.forEach((s) =>
expect(contrast(t[p][`Container${ s}`], t[p].OnContainer)).toBeGreaterThanOrEqual(AA)
)
);
});
it('clears AA for button labels and tinted-container text in every state', () => {
roles.forEach((r) =>
states.forEach((s) => {
expect(contrast(t[r][`Main${ s}`], t[r].OnMain)).toBeGreaterThanOrEqual(AA);
expect(contrast(t[r][`Container${ s}`], t[r].OnContainer)).toBeGreaterThanOrEqual(AA);
})
);
});
it('clears AA for message text in both bubbles, links and names', () => {
expect(contrast(sky.bubbles.Other, sky.bubbles.OtherText)).toBeGreaterThanOrEqual(AA);
expect(contrast(sky.bubbles.Own, sky.bubbles.OwnText)).toBeGreaterThanOrEqual(AA);
const grounds = [
t.Background.Container,
t.Surface.Container,
t.SurfaceVariant.Container,
sky.bubbles.Other,
sky.bubbles.Own,
];
grounds.forEach((g) => {
expect(contrast(sky.link, g)).toBeGreaterThanOrEqual(AA);
sky.userColors.forEach((c: string) => expect(contrast(c, g)).toBeGreaterThanOrEqual(AA));
});
});
it('clears AA for body text straight over the fallback sky gradient', () => {
sky.gradient.forEach((g: string) =>
expect(contrast(t.Surface.OnContainer, g)).toBeGreaterThanOrEqual(AA)
);
});
});