sky: Midnight Sky and Noonday Sky baked from sun.js (albedo x irradiance)
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:
parent
7c89bd0289
commit
7b4aa2f184
11 changed files with 573 additions and 131 deletions
|
|
@ -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
13
scripts/bake-sky.mjs
Normal 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`
|
||||
);
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export interface Settings {
|
|||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
themeId: 'gamecult-theme',
|
||||
themeId: 'midnight-sky',
|
||||
useSystemTheme: false,
|
||||
lightThemeId: undefined,
|
||||
darkThemeId: undefined,
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
29
src/sky.css.ts
Normal 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
222
src/sky/baked.ts
Normal 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
202
src/sky/skyTokens.js
Normal 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
59
src/sky/skyTokens.test.ts
Normal 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)
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue