Sky palette: dawn, day and dusk-night themes replace the GameCult navy theme (do not merge) #18
11 changed files with 391 additions and 177 deletions
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)';
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export interface Settings {
|
|||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
themeId: 'gamecult-theme',
|
||||
themeId: 'sky-dusk',
|
||||
useSystemTheme: false,
|
||||
lightThemeId: undefined,
|
||||
darkThemeId: undefined,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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
48
src/sky.css.ts
Normal 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
97
src/skyPalette.test.ts
Normal 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
190
src/skyPalette.ts
Normal 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];
|
||||
};
|
||||
Loading…
Reference in a new issue