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