diff --git a/index.html b/index.html index c9042aff..86919550 100644 --- a/index.html +++ b/index.html @@ -19,7 +19,7 @@ property="og:description" content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source." /> - + diff --git a/public/manifest.json b/public/manifest.json index ef0a3510..2a5532ef 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -7,8 +7,8 @@ "display": "standalone", "orientation": "portrait", "start_url": "./", - "background_color": "#07111a", - "theme_color": "#07111a", + "background_color": "#1d1238", + "theme_color": "#1d1238", "icons": [ { "src": "./public/android/android-chrome-36x36.png", diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 4bfe01da..718d5b38 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -1,8 +1,9 @@ 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 '../../gamecult.css'; +import { butterTheme, darkTheme, silverTheme, skyThemes } from '../../colors.css'; +import { DEFAULT_SKY_ID, SKY_VARIANTS } from '../../skyPalette'; +import '../../sky.css'; import { settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; @@ -39,14 +40,26 @@ export const ButterTheme: Theme = { classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'], }; -export const GameCultTheme: Theme = { - id: 'gamecult-theme', - kind: ThemeKind.Dark, - classNames: ['gamecult-theme', gamecultTheme, onDarkFontWeight, 'prism-dark'], -}; +export const SkyThemes: Theme[] = SKY_VARIANTS.map((v) => ({ + id: v.id, + kind: v.kind === 'dark' ? ThemeKind.Dark : ThemeKind.Light, + classNames: [ + 'sky-theme', + v.id, + skyThemes[v.id], + v.kind === 'dark' ? onDarkFontWeight : onLightFontWeight, + v.kind === 'dark' ? 'prism-dark' : 'prism-light', + ], +})); +const skyTheme = (id: string) => SkyThemes.find((t) => t.id === id) as Theme; +export const DefaultTheme: Theme = skyTheme(DEFAULT_SKY_ID); +const DaySkyTheme = skyTheme('sky-day'); export const useThemes = (): Theme[] => { - const themes: Theme[] = useMemo(() => [GameCultTheme, LightTheme, SilverTheme, DarkTheme, ButterTheme], []); + const themes: Theme[] = useMemo( + () => [...SkyThemes, LightTheme, SilverTheme, DarkTheme, ButterTheme], + [] + ); return themes; }; @@ -54,7 +67,7 @@ export const useThemes = (): Theme[] => { export const useThemeNames = (): Record => useMemo( () => ({ - [GameCultTheme.id]: 'GameCult', + ...Object.fromEntries(SKY_VARIANTS.map((v) => [v.id, `Sky ${v.name}`])), [LightTheme.id]: 'Light', [SilverTheme.id]: 'Silver', [DarkTheme.id]: 'Dark', @@ -92,15 +105,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) ?? DefaultTheme; 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) ?? DefaultTheme + : themes.find((theme) => theme.id === lightThemeId) ?? DaySkyTheme; return selectedTheme; }; diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index 983ca684..a3636738 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -1,19 +1,22 @@ import React, { ReactNode, useEffect } from 'react'; import { configClass, varsClass } from 'folds'; -import { - GameCultTheme, - ThemeContextProvider, - useActiveTheme, -} from '../hooks/useTheme'; +import { DefaultTheme, ThemeContextProvider, useActiveTheme } from '../hooks/useTheme'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; +import { chromeColor } from '../../skyPalette'; + +// Browser chrome follows the sky at the top edge; the stock themes keep the static tag. +const setChromeColor = (themeId: string) => { + document.querySelector('meta[name="theme-color"]')?.setAttribute('content', chromeColor(themeId)); +}; 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 ignore the system scheme and wear the default sky. + document.body.classList.add(...DefaultTheme.classNames); + setChromeColor(DefaultTheme.id); }, []); return null; @@ -28,6 +31,7 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { document.body.classList.add(configClass, varsClass); document.body.classList.add(...activeTheme.classNames); + setChromeColor(activeTheme.id); if (monochromeMode) { document.body.style.filter = 'grayscale(1)'; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index d4deb39c..09815760 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: 'sky-dusk', useSystemTheme: false, lightThemeId: undefined, darkThemeId: undefined, diff --git a/src/colors.css.ts b/src/colors.css.ts index 9c03c2cb..7e3298be 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 { SKY_VARIANTS, foldsTheme } from './skyPalette'; export const silverTheme = createTheme(color, { Background: { @@ -237,94 +238,7 @@ 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', - }, - Other: { - FocusRing: 'rgba(89, 183, 255, 0.55)', - Shadow: 'rgba(0, 0, 0, 0.6)', - Overlay: 'rgba(3, 7, 13, 0.8)', - }, -}); +// Sky themes (dawn, day, dusk-night): every colour is chosen in skyPalette.ts. +export const skyThemes = Object.fromEntries( + SKY_VARIANTS.map((v) => [v.id, createTheme(color, foldsTheme(v))]) +) as Record; diff --git a/src/gamecult.css.ts b/src/gamecult.css.ts deleted file mode 100644 index 4aaf3e76..00000000 --- a/src/gamecult.css.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { globalStyle } from '@vanilla-extract/css'; -import { config } from 'folds'; - -// Softer, rounder shape language and an airier line height. Scoped to the GameCult -// theme class so the stock themes keep folds' defaults. -const scope = 'body.gamecult-theme'; - -globalStyle(scope, { - vars: { - [config.radii.R300]: '10px', - [config.radii.R400]: '16px', - [config.radii.R500]: '22px', - [config.lineHeight.T300]: '1.6', - [config.lineHeight.T400]: '1.7', - [config.lineHeight.T500]: '1.7', - [config.lineHeight.B400]: '1.6', - [config.lineHeight.B500]: '1.6', - [config.lineHeight.D400]: '1.7', - }, -}); diff --git a/src/index.css b/src/index.css index f751b94d..1d14f043 100644 --- a/src/index.css +++ b/src/index.css @@ -136,66 +136,34 @@ li p::before { display: inline-block; } -/* GameCult identity: 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%); - +/* Sky themes: Ubuntu 300 prose, Montserrat thin titles, IBM Plex Mono labels. Colours, + gradient and selection live in sky.css.ts, from skyPalette.ts. */ +.sky-theme { --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; } -.gamecult-theme b, -.gamecult-theme strong { +.sky-theme b, +.sky-theme strong { font-weight: 500; } -.gamecult-theme h1, -.gamecult-theme h2, -.gamecult-theme h3 { +.sky-theme h1, +.sky-theme h2, +.sky-theme h3 { font-family: var(--font-title); font-weight: 300; } -.gamecult-theme code, -.gamecult-theme pre { +.sky-theme code, +.sky-theme pre { font-family: var(--font-mono); } /* Quiet secondary metadata: timestamps, edited marks and the like sit back. */ -.gamecult-theme time { +.sky-theme time { opacity: 0.55; font-size: 0.78em; } diff --git a/src/sky.css.ts b/src/sky.css.ts new file mode 100644 index 00000000..573fd849 --- /dev/null +++ b/src/sky.css.ts @@ -0,0 +1,48 @@ +import { globalStyle } from '@vanilla-extract/css'; +import { config } from 'folds'; +import { SKY_VARIANTS, skyGradientCss } from './skyPalette'; + +// Softer, rounder shape language and an airier line height. Scoped to the sky theme +// class so the stock themes keep folds' defaults. +globalStyle('body.sky-theme', { + vars: { + [config.radii.R300]: '10px', + [config.radii.R400]: '16px', + [config.radii.R500]: '22px', + [config.lineHeight.T300]: '1.6', + [config.lineHeight.T400]: '1.7', + [config.lineHeight.T500]: '1.7', + [config.lineHeight.B400]: '1.6', + [config.lineHeight.B500]: '1.6', + [config.lineHeight.D400]: '1.7', + }, +}); + +// One fixed layer carries the sky. Panels are translucent tints over it (skyPalette.ts); +// nothing else paints a gradient, and no message does. +globalStyle('body.sky-theme::before', { + content: '""', + position: 'fixed', + inset: 0, + zIndex: -1, + pointerEvents: 'none', +}); + +SKY_VARIANTS.forEach((v) => { + const top = v.gradient[0][1]; + globalStyle(`body.${v.id}`, { + backgroundColor: top, // solid fallback until the layer paints + colorScheme: v.kind, + }); + globalStyle(`body.${v.id}::before`, { background: skyGradientCss(v) }); + globalStyle(`body.${v.id} ::selection`, { + backgroundColor: v.leaf, + color: v.kind === 'dark' ? '#10270f' : '#ffffff', + }); + globalStyle(`body.${v.id}`, { + vars: { + '--tc-link': v.link, + ...Object.fromEntries(v.userColors.map((c, i) => [`--mx-uc-${i + 1}`, c])), + }, + }); +}); diff --git a/src/skyPalette.test.ts b/src/skyPalette.test.ts new file mode 100644 index 00000000..084ee6e8 --- /dev/null +++ b/src/skyPalette.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { SKY_VARIANTS, foldsTheme, hex, type Rgb, type SkyVariant } from './skyPalette'; + +const AA = 4.5; + +const lin = (v: number) => { + const s = v / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; +}; +const lum = (c: Rgb) => 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]); +const ratio = (a: Rgb, b: Rgb) => { + const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +}; +// "rgb(r g b / a)" or "#rrggbb" to [r, g, b, a] +const parse = (s: string): [number, number, number, number] => { + if (s.startsWith('#')) return [...hex(s), 1]; + const m = s.match(/rgb\((\d+) (\d+) (\d+) \/ ([\d.]+)\)/); + if (!m) throw new Error(`unparsed colour ${s}`); + return [+m[1], +m[2], +m[3], +m[4]]; +}; +const over = (top: [number, number, number, number], under: Rgb): Rgb => [ + top[0] * top[3] + under[0] * (1 - top[3]), + top[1] * top[3] + under[1] * (1 - top[3]), + top[2] * top[3] + under[2] * (1 - top[3]), +]; + +// every percent of the gradient, interpolated between stops +const sky = (v: SkyVariant): Rgb[] => + Array.from({ length: 101 }, (_, p) => { + const i = Math.max( + 1, + v.gradient.findIndex(([q]) => q >= p) + ); + const [p1, c1] = v.gradient[i === 0 ? 0 : i]; + const [p0, c0] = v.gradient[i - 1]; + const t = Math.min(1, Math.max(0, (p - p0) / (p1 - p0))); + const a = hex(c0); + const b = hex(c1); + return a.map((x, k) => x + (b[k] - x) * t) as unknown as Rgb; + }); + +describe.each(SKY_VARIANTS)('$id contrast', (v) => { + const t = foldsTheme(v); + const report: string[] = []; + + // Text on a panel is judged where the panel sits on the sky alone, and where Surface or + // SurfaceVariant sits on the Background panel on the sky; the lowest ratio over the + // gradient is the one that counts. + const panels = ['Background', 'Surface', 'SurfaceVariant'] as const; + const states = ['Container', 'ContainerHover', 'ContainerActive'] as const; + + it.each(panels.flatMap((p) => states.map((s) => [p, s] as const)))( + 'OnContainer on %s %s holds AA at every gradient point', + (p, s) => { + const fg = hex(t[p].OnContainer); + const ratios = sky(v).flatMap((g) => { + const bg = over(parse(t.Background.Container), g); + const direct = ratio(fg, over(parse(t[p][s]), g)); + return p === 'Background' ? [direct] : [direct, ratio(fg, over(parse(t[p][s]), bg))]; + }); + const worst = Math.min(...ratios); + report.push(`${p}.${s} ${worst.toFixed(2)}`); + expect(worst).toBeGreaterThanOrEqual(AA); + } + ); + + it('link and user colours hold AA on Surface at every gradient point', () => { + const colors = [v.link, ...v.userColors]; + colors.forEach((c) => { + const ratios = sky(v).flatMap((g) => { + const bg = over(parse(t.Surface.Container), over(parse(t.Background.Container), g)); + return [ratio(hex(c), bg), ratio(hex(c), over(parse(t.Surface.Container), g))]; + }); + const worst = Math.min(...ratios); + report.push(`${c} ${worst.toFixed(2)}`); + expect(worst).toBeGreaterThanOrEqual(AA); + }); + }); + + it.each(['Primary', 'Secondary', 'Success', 'Warning', 'Critical'] as const)( + '%s: OnMain on Main/Hover/Active and OnContainer on Container states hold AA', + (k) => { + const f = t[k]; + (['Main', 'MainHover', 'MainActive'] as const).forEach((s) => + expect(ratio(hex(f.OnMain), hex(f[s]))).toBeGreaterThanOrEqual(AA) + ); + (['Container', 'ContainerHover', 'ContainerActive'] as const).forEach((s) => + expect(ratio(hex(f.OnContainer), hex(f[s]))).toBeGreaterThanOrEqual(AA) + ); + } + ); + + it('prints the ratios when SKY_REPORT is set', () => { + if (process.env.SKY_REPORT) console.log(`${v.id}\n${report.join('\n')}`); // eslint-disable-line no-console + }); +}); diff --git a/src/skyPalette.ts b/src/skyPalette.ts new file mode 100644 index 00000000..39fa0af3 --- /dev/null +++ b/src/skyPalette.ts @@ -0,0 +1,190 @@ +// The sky palette: three themes (dawn, day, dusk-night) whose background is one fixed sky +// gradient and whose elements (buttons, accents, links, borders, selection) are Yggdrasil +// greens and bark/root browns. This module is the only place a sky colour is chosen; +// colors.css.ts, sky.css.ts, the manifest and skyPalette.test.ts all read it. The test pins WCAG AA +// for text against every translucent panel over every point of the gradient. + +export type Rgb = readonly [number, number, number]; +export type Rgba = readonly [number, number, number, number]; +export type SkyKind = 'light' | 'dark'; +export type SkyStop = readonly [position: number, color: string]; + +export type SkyVariant = { + id: string; + name: string; + kind: SkyKind; + gradient: readonly SkyStop[]; + ink: string; // message text + panel: string; // Background panel tint, Surface and SurfaceVariant derive from it + panelAlpha: readonly [background: number, surface: number, variant: number]; + leaf: string; // Yggdrasil green: Primary + bark: string; // bark/root brown: Secondary + link: string; + userColors: readonly string[]; +}; + +export const hex = (c: string): Rgb => + [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)) as unknown as Rgb; +const toHex = (c: Rgb) => `#${c.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`; +const mix = (a: Rgb, b: Rgb, t: number): Rgb => [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, +]; +const WHITE: Rgb = [255, 255, 255]; +const BLACK: Rgb = [0, 0, 0]; +export const rgba = (c: Rgb, a: number) => + `rgb(${Math.round(c[0])} ${Math.round(c[1])} ${Math.round(c[2])} / ${a})`; + +export const SKY_VARIANTS: readonly SkyVariant[] = [ + { + id: 'sky-dawn', + name: 'Dawn', + kind: 'light', + gradient: [ + [0, '#5b4a8a'], + [38, '#c9729a'], + [72, '#f4a77a'], + [100, '#fbe0a8'], + ], + ink: '#2b1d14', + panel: '#fff6ec', + panelAlpha: [0.68, 0.86, 0.9], + leaf: '#2e6a38', + bark: '#5a3a26', + link: '#1f5f2c', + userColors: [ + '#7a3d00', + '#8a2a66', + '#1a6b4a', + '#a3243f', + '#8a4200', + '#12677a', + '#4b3aa6', + '#4a6b12', + ], + }, + { + id: 'sky-day', + name: 'Day', + kind: 'light', + gradient: [ + [0, '#3d86d6'], + [48, '#8cc4ee'], + [100, '#e1f1f4'], + ], + ink: '#1d2a1a', + panel: '#f8fbf4', + panelAlpha: [0.66, 0.86, 0.9], + leaf: '#2a6a34', + bark: '#5a3e2b', + link: '#1c5e2a', + userColors: [ + '#0a5a96', + '#8a2a66', + '#1a6b4a', + '#a3243f', + '#8a4200', + '#12677a', + '#4b3aa6', + '#4a6b12', + ], + }, + { + id: 'sky-dusk', + name: 'Dusk', + kind: 'dark', + gradient: [ + [0, '#1d1238'], + [40, '#4a2a63'], + [75, '#a2455a'], + [100, '#e08a4a'], + ], + ink: '#f3ece4', + panel: '#181020', + panelAlpha: [0.7, 0.86, 0.9], + leaf: '#8fd18a', + bark: '#d2b79a', + link: '#a8e0a2', + userColors: [ + '#8cc8ff', + '#f0a0ff', + '#6fe0b0', + '#ff93ad', + '#ffb36b', + '#6fdce0', + '#b3a8ff', + '#b8e87a', + ], + }, +]; + +const family = (v: SkyVariant, main: Rgb, onMain: Rgb) => { + const dark = v.kind === 'dark'; + const ink = hex(v.ink); + const toward = dark ? WHITE : BLACK; + const panel = hex(v.panel); + const cont = mix(panel, main, dark ? 0.22 : 0.16); // opaque tinted panel under text + return { + Main: toHex(main), + MainHover: toHex(mix(main, toward, 0.12)), + MainActive: toHex(mix(main, toward, 0.2)), + MainLine: toHex(mix(main, toward, 0.28)), + OnMain: toHex(onMain), + Container: toHex(cont), + ContainerHover: toHex(mix(cont, main, 0.1)), + ContainerActive: toHex(mix(cont, main, 0.18)), + ContainerLine: toHex(mix(cont, main, 0.35)), + OnContainer: toHex(mix(ink, main, dark ? 0.12 : 0.18)), + }; +}; + +const panelSet = (v: SkyVariant, alpha: number) => { + const dark = v.kind === 'dark'; + const base = hex(v.panel); + const ink = hex(v.ink); + const t = dark ? 0.07 : 0.06; + return { + Container: rgba(base, alpha), + ContainerHover: rgba(mix(base, ink, t), alpha), + ContainerActive: rgba(mix(base, ink, t * 2), alpha), + ContainerLine: rgba(mix(base, ink, 0.3), Math.min(1, alpha + 0.05)), + OnContainer: v.ink, + }; +}; + +// folds' colour contract, one theme per variant. +export const foldsTheme = (v: SkyVariant) => { + const dark = v.kind === 'dark'; + const leaf = hex(v.leaf); + const bark = hex(v.bark); + const onLeaf: Rgb = dark ? hex('#10270f') : WHITE; + const onBark: Rgb = dark ? hex('#2a1b10') : hex('#fbf3ea'); + const status = (light: string, darkC: string) => + family(v, hex(dark ? darkC : light), dark ? hex('#10140d') : WHITE); + return { + Background: panelSet(v, v.panelAlpha[0]), + Surface: panelSet(v, v.panelAlpha[1]), + SurfaceVariant: panelSet(v, v.panelAlpha[2]), + Primary: family(v, leaf, onLeaf), + Secondary: family(v, bark, onBark), + Success: status('#1f7a4a', '#7fe0b0'), + Warning: status('#8a4a00', '#e8b866'), + Critical: status('#a01c1c', '#f09a9a'), + Other: { + FocusRing: rgba(leaf, 0.7), + Shadow: dark ? 'rgb(0 0 0 / 0.6)' : 'rgb(43 29 20 / 0.25)', + Overlay: dark ? 'rgb(12 6 20 / 0.8)' : 'rgb(43 29 20 / 0.5)', + }, + }; +}; + +export const skyGradientCss = (v: SkyVariant) => + `linear-gradient(180deg, ${v.gradient.map(([p, c]) => `${c} ${p}%`).join(', ')})`; + +// Chrome colour (meta theme-color, manifest): the default theme's sky at the top edge. +export const DEFAULT_SKY_ID = 'sky-dusk'; +export const chromeColor = (id: string = DEFAULT_SKY_ID) => { + const v = SKY_VARIANTS.find((s) => s.id === id) ?? SKY_VARIANTS[0]; + return v.gradient[0][1]; +};