From 7b4aa2f184d0871daee5cce85aaee6cb439c03ac Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 6 Nov 2026 20:57:31 +0000 Subject: [PATCH] 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 --- package.json | 1 + scripts/bake-sky.mjs | 13 ++ src/app/hooks/useTheme.ts | 35 ++++-- src/app/pages/ThemeManager.tsx | 6 +- src/app/state/settings.ts | 2 +- src/colors.css.ts | 100 +++------------ src/index.css | 35 +----- src/sky.css.ts | 29 +++++ src/sky/baked.ts | 222 +++++++++++++++++++++++++++++++++ src/sky/skyTokens.js | 202 ++++++++++++++++++++++++++++++ src/sky/skyTokens.test.ts | 59 +++++++++ 11 files changed, 573 insertions(+), 131 deletions(-) create mode 100644 scripts/bake-sky.mjs create mode 100644 src/sky.css.ts create mode 100644 src/sky/baked.ts create mode 100644 src/sky/skyTokens.js create mode 100644 src/sky/skyTokens.test.ts diff --git a/package.json b/package.json index 298fbf36..26d64aea 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/bake-sky.mjs b/scripts/bake-sky.mjs new file mode 100644 index 00000000..4e9d015a --- /dev/null +++ b/scripts/bake-sky.mjs @@ -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` +); diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 4bfe01da..d3663283 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -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 => 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; }; diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index 983ca684..e730b00d 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -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; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index d4deb39c..fa4ea4c5 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -56,7 +56,7 @@ export interface Settings { } const defaultSettings: Settings = { - themeId: 'gamecult-theme', + themeId: 'midnight-sky', useSystemTheme: false, lightThemeId: undefined, darkThemeId: undefined, diff --git a/src/colors.css.ts b/src/colors.css.ts index 9c03c2cb..b4569b8b 100644 --- a/src/colors.css.ts +++ b/src/colors.css.ts @@ -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)', }, }); diff --git a/src/index.css b/src/index.css index f751b94d..99b355c0 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } diff --git a/src/sky.css.ts b/src/sky.css.ts new file mode 100644 index 00000000..341d0eb7 --- /dev/null +++ b/src/sky.css.ts @@ -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, + }, + }); +}); diff --git a/src/sky/baked.ts b/src/sky/baked.ts new file mode 100644 index 00000000..15e72c8f --- /dev/null +++ b/src/sky/baked.ts @@ -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; diff --git a/src/sky/skyTokens.js b/src/sky/skyTokens.js new file mode 100644 index 00000000..0df39c33 --- /dev/null +++ b/src/sky/skyTokens.js @@ -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), + }; +} diff --git a/src/sky/skyTokens.test.ts b/src/sky/skyTokens.test.ts new file mode 100644 index 00000000..0cd6694c --- /dev/null +++ b/src/sky/skyTokens.test.ts @@ -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) + ); + }); +});