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