Sky palette: dawn, day and dusk-night themes replace the GameCult navy theme (do not merge) #18

Closed
void wants to merge 1 commit from void/ratatoskr-sky-palette into void/brand-ratatoskr
11 changed files with 391 additions and 177 deletions

View file

@ -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."
/>
<meta name="theme-color" content="#07111a" />
<meta name="theme-color" content="#1d1238" />
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />

View file

@ -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",

View file

@ -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<string, string> =>
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;
};

View file

@ -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)';

View file

@ -56,7 +56,7 @@ export interface Settings {
}
const defaultSettings: Settings = {
themeId: 'gamecult-theme',
themeId: 'sky-dusk',
useSystemTheme: false,
lightThemeId: undefined,
darkThemeId: undefined,

View file

@ -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<string, string>;

View file

@ -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',
},
});

View file

@ -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;
}

48
src/sky.css.ts Normal file
View file

@ -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])),
},
});
});

97
src/skyPalette.test.ts Normal file
View file

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

190
src/skyPalette.ts Normal file
View file

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