Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions src/components/organisms/CreateCanvas/ShowcaseCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ const extras = (title: string, view: CardExtras['view']): CardExtras => ({
readOnly: true,
});

const theme = deriveTheme(DEFAULT_THEME);
// The section's own ground for the cards is the site's off-white, in place of
// the canvas's white.
const theme = deriveTheme(DEFAULT_THEME, '#f6f6e8');
const widgets = cards as Widget[];

type Tile = Rect & { key: number; card: number; leaving: boolean };
Expand Down Expand Up @@ -275,9 +277,6 @@ const ShowcaseCards = ({ grid }: { grid: Grid }) => {
height: h * gridCell,
scale: SCALE,
...(themeVars(theme) as React.CSSProperties),
// The section's own ground for the cards — the
// site's off-white instead of the canvas's white.
['--th-card' as string]: '#f6f6e8',
}}
>
<CardBody
Expand Down
13 changes: 10 additions & 3 deletions src/components/organisms/Footer/Footer.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@

font-family: var(--font-dm-mono);
font-weight: 500;
color: var(--color-sage);
color: var(--color-net-light);
letter-spacing: -0.01em;
}

Expand All @@ -161,13 +161,18 @@

font-family: var(--font-dm-mono);
font-weight: 300;
color: var(--color-stone-light);

// Net-light holds 4.5:1 on the charcoal face (7.7) and on the map
// strokes that cross the text (5.5). Stone-light and sage fall below it.
color: var(--color-net-light);
text-decoration: none;
letter-spacing: -0.01em;
transition: color 0.2s ease;

&:hover {
color: var(--color-step-text);
text-decoration: underline;
text-underline-offset: rem(3);
}
}
}
Expand Down Expand Up @@ -210,13 +215,15 @@

font-family: var(--font-dm-mono);
font-weight: 300;
color: var(--color-stone-light);
color: var(--color-net-light);
text-decoration: none;
letter-spacing: -0.01em;
transition: color 0.2s ease;

&:hover {
color: var(--color-step-text);
text-decoration: underline;
text-underline-offset: rem(3);
}
}

Expand Down
61 changes: 59 additions & 2 deletions src/lib/studio/test/theme.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
/**
* `theme.ts`: every palette carries ten distinct series colours, so a chart
* of up to ten series never draws two the same, and the first eight stay the
* colours saved charts were drawn in.
* colours saved charts were drawn in. Muted text and captions read at
* 4.5:1 on every card face.
*/
import assert from 'node:assert/strict';
import test from 'node:test';

import { paletteFromAccent, THEME_PRESETS, deriveTheme } from '../theme.ts';
import {
DEFAULT_THEME,
deriveTheme,
paletteFromAccent,
type StudioTheme,
THEME_PRESETS,
} from '../theme.ts';

const CHART_LINES = 10;

Expand Down Expand Up @@ -153,3 +160,53 @@ test('a custom accent keeps its first eight colours, 45° apart', () => {
['#101010', '#826f35', '#7aae47', '#2c6d3c', '#3e9898', '#2c3c6d', '#7544a7', '#742f63'],
);
});

// WCAG 2 contrast, computed here independently of theme.ts, so the floor has a second check.
const wcagLuminance = (hex: string): number => {
const n = parseInt(hex.slice(1), 16);
const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255]
.map((v) => v / 255)
.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)) as [
number,
number,
number,
];

return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};

const ratio = (a: string, b: string): number => {
const [la, lb] = [wcagLuminance(a), wcagLuminance(b)];

return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
};

const FLOOR_CASES: [name: string, theme: StudioTheme, face?: string][] = [
...THEME_PRESETS.map(({ id }): [string, StudioTheme] => [id, { preset: id, overrides: {} }]),
['default on the showcase face', DEFAULT_THEME, '#f6f6e8'],
['light custom card', { preset: 'default', overrides: { card: '#f0e6d2', text: '#8a8a80' } }],
['dark custom card', { preset: 'dark', overrides: { card: '#1a1a2e', text: '#7a7a90' } }],
['mid-grey custom card', { preset: 'mono', overrides: { card: '#777777', text: '#888888' } }],
];

test('muted text and captions hold 4.5:1 on their card', () => {
for (const [name, theme, face] of FLOOR_CASES) {
const derived = deriveTheme(theme, face);

if (face) assert.equal(derived.card, face, name);

for (const role of ['muted', 'caption'] as const) {
const got = ratio(derived[role], derived.card);

assert.ok(got >= 4.5, `${name} ${role} ${derived[role]} is ${got.toFixed(2)}:1`);
}
}
});

test('the default caption darkens to the floor and keeps its cool grey', () => {
// #abb4b7 scaled toward black, on white and on the showcase face.
assert.equal(deriveTheme(DEFAULT_THEME).caption, '#727779');
assert.equal(deriveTheme(DEFAULT_THEME, '#f6f6e8').caption, '#6c7173');
// Muted already clears the floor and stays the house stone.
assert.equal(deriveTheme(DEFAULT_THEME, '#f6f6e8').muted, '#5e5e52');
});
73 changes: 66 additions & 7 deletions src/lib/studio/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@ export const THEME_PRESETS: ThemePreset[] = [
tokens: { accent: '#6467de', card: '#ffffff', text: '#484842' },
series: DEFAULT_SERIES,
swatches: ['#d2d2c5', '#e8e8d5', '#c6cebc', '#484842', '#f7f7ee'],
// The house palette, exactly: net-light lines, stone muted text,
// gray-1500 card captions, popup face, and the original glow shades.
// The house palette: net-light lines, stone muted text,
// gray-1500 card captions (darkened to 4.5:1 on the card face), popup
// face, and the original glow shades.
derived: {
line: '#ececdb',
muted: '#5e5e52',
Expand Down Expand Up @@ -311,14 +312,68 @@ export const isDark = (hex: string): boolean => {
return (r * 299 + g * 587 + b * 114) / 1000 < 128;
};

// WCAG 2 relative luminance and contrast ratio.
const luminance = (hex: string): number => {
const [r, g, b] = hexToRgb(hex)
.map((v) => v / 255)
.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)) as [
number,
number,
number,
];

return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};

const contrast = (a: string, b: string): number => {
const [la, lb] = [luminance(a), luminance(b)];

return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
};

// WCAG AA for body text.
const TEXT_CONTRAST = 4.5;

// Returns `hex` unchanged when it holds 4.5:1 on `face`, else scaled toward black (or toward white, on faces where white
// contrasts more) just far enough to reach it. Scaling every channel by
// the same factor keeps the colour's hue, so a cool grey stays a cool grey.
// Every face reaches 4.5:1 against black or white, so the search always
// lands.
const legible = (hex: string, face: string): string => {
const target = contrast('#000000', face) >= contrast('#ffffff', face) ? '#000000' : '#ffffff';
const faceLight = target === '#000000';
const reads = (colour: string) =>
luminance(colour) < luminance(face) === faceLight &&
contrast(colour, face) >= TEXT_CONTRAST;

if (reads(hex)) {
return hex;
}

let [lo, hi] = [0, 1];

for (let i = 0; i < 24; i += 1) {
const mid = (lo + hi) / 2;

if (reads(mix(hex, target, mid))) {
hi = mid;
} else {
lo = mid;
}
}

return mix(hex, target, hi);
};

// The full set of derived colours a theme needs, from the four tokens.
export type ThemeDerived = ThemeTokens & {
// Card border / grid lines / hairlines: text tinted into the surface.
line: string;
// Muted text (axis, secondary labels).
muted: string;
// Card captions — the source tag and "Built with": a step quieter than
// muted, so they recede under the data.
// muted, so they recede under the data. Muted and caption both hold
// 4.5:1 on the card, which can bring the two together.
caption: string;
// Popup / menu face: card nudged toward text.
popup: string;
Expand All @@ -330,9 +385,13 @@ export type ThemeDerived = ThemeTokens & {
series: SeriesPair[];
};

export const deriveTheme = (theme: StudioTheme): ThemeDerived => {
// `face` is the card face when the host paints its own (the home page's
// showcase sits its cards on the site's off-white). The preset's hand-set
// colours apply, and the text floor is measured against that face.
export const deriveTheme = (theme: StudioTheme, face?: string): ThemeDerived => {
const preset = presetOf(theme);
const tokens = resolveTokens(theme);
const resolved = resolveTokens(theme);
const tokens = face ? { ...resolved, card: face } : resolved;
const dark = isDark(tokens.card);
// A preset's hand-set derived colours hold as long as its surface
// tokens do; overriding card/text falls back to the mixed derivation.
Expand All @@ -342,8 +401,8 @@ export const deriveTheme = (theme: StudioTheme): ThemeDerived => {
return {
...tokens,
line: hand.line ?? mix(tokens.card, tokens.text, dark ? 0.16 : 0.09),
muted: hand.muted ?? mix(tokens.text, tokens.card, 0.45),
caption: hand.caption ?? mix(tokens.text, tokens.card, 0.6),
muted: legible(hand.muted ?? mix(tokens.text, tokens.card, 0.45), tokens.card),
caption: legible(hand.caption ?? mix(tokens.text, tokens.card, 0.6), tokens.card),
popup: hand.popup ?? mix(tokens.card, tokens.text, dark ? 0.12 : 0.1),
glow: hand.glow ?? [
mix(CANVAS_BG, tokens.text, 0.1),
Expand Down
Loading