Skip to content

Commit db50bf4

Browse files
authored
Merge pull request #111 from cortex-docs/pre-release
fix: adapt primary button text to configured colors
2 parents a18b627 + 1204eea commit db50bf4

6 files changed

Lines changed: 110 additions & 67 deletions

File tree

‎e2e/docs-ui-static.spec.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,29 @@ test.describe('Cloudflare Static Assets export', () => {
3030
await expect(page.locator('.cortex-cookie-settings-button')).toHaveCount(0);
3131
});
3232

33+
for (const appearance of ['light', 'dark']) {
34+
for (const [color, background, foreground] of [
35+
['#c2410c', 'rgb(194, 65, 12)', 'rgb(255, 255, 255)'],
36+
['#2563eb', 'rgb(37, 99, 235)', 'rgb(255, 255, 255)'],
37+
['#7c3aed', 'rgb(124, 58, 237)', 'rgb(255, 255, 255)'],
38+
['#ffffff', 'rgb(255, 255, 255)', 'rgb(10, 10, 10)'],
39+
]) {
40+
test(`adapts the homepage button text to ${color} in ${appearance} mode`, async ({
41+
page,
42+
}) => {
43+
await page.route('**/api/config*', async (route) => {
44+
const response = await route.fetch();
45+
const config = await response.json();
46+
await route.fulfill({ json: { ...config, primaryColor: color } });
47+
});
48+
await page.goto(`/?appearance=${appearance}`);
49+
const button = page.getByRole('link', { name: 'Getting Started', exact: true });
50+
await expect(button).toHaveCSS('background-color', background);
51+
await expect(button).toHaveCSS('color', foreground);
52+
});
53+
}
54+
}
55+
3356
test('supports client navigation between generated documentation pages', async ({ page }) => {
3457
await page.goto('/docs/quickstart');
3558
await expect(page).toHaveTitle('Petstore Docs');
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { primaryPalette, primaryThemeCss } from '../lib/primary-theme';
3+
4+
describe('primary button contrast', () => {
5+
it.each(['#c2410c', '#2563eb', '#7c3aed', '#000000', '#0000ff'])(
6+
'uses white text on %s',
7+
(color) => {
8+
expect(primaryPalette(color).foreground).toBe('#ffffff');
9+
},
10+
);
11+
12+
it.each(['#ffffff', '#ffff00', '#00ff00', '#f97316', '#ff0000'])(
13+
'uses dark text on a light fill (%s)',
14+
(color) => {
15+
expect(primaryPalette(color).foreground).toBe('#0a0a0a');
16+
},
17+
);
18+
19+
it('keeps the chosen button fill instead of brightening it in dark mode', () => {
20+
expect(primaryPalette('#2563eb').background).toBe('rgb(37,99,235)');
21+
expect(primaryThemeCss('#2563eb').match(/--color-primary:rgb\(37,99,235\)/g)).toHaveLength(3);
22+
});
23+
24+
it('ignores invalid colors before producing CSS', () => {
25+
expect(primaryThemeCss('red')).toBe('');
26+
expect(primaryThemeCss('#fff; color:red')).toBe('');
27+
});
28+
});

‎packages/docs-ui/app/layout.tsx‎

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { SearchProvider } from '@/components/docs/search-provider';
1414
import { GoogleAnalytics } from '@/components/docs/google-analytics';
1515
import { sanitizeSvg } from '@/lib/sanitize-svg';
16+
import { primaryThemeCss } from '@/lib/primary-theme';
1617

1718
interface LoadedSiteConfig extends SiteConfig {
1819
customHeadHtml?: string;
@@ -27,33 +28,6 @@ function emptySiteConfig(): LoadedSiteConfig {
2728
};
2829
}
2930

30-
function adj(r: number, g: number, b: number, mul: number) {
31-
return `rgb(${Math.min(255, Math.round(r * mul))},${Math.min(255, Math.round(g * mul))},${Math.min(255, Math.round(b * mul))})`;
32-
}
33-
34-
function parsePrimary(hex: string) {
35-
const r = parseInt(hex.slice(1, 3), 16);
36-
const g = parseInt(hex.slice(3, 5), 16);
37-
const b = parseInt(hex.slice(5, 7), 16);
38-
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
39-
const lightBgMul = lum < 0.15 ? 1.8 : lum < 0.3 ? 1.3 : 1;
40-
const darkBgMul = lum < 0.15 ? 4 : lum < 0.3 ? 2.2 : lum < 0.4 ? 1.4 : 1;
41-
const textMul =
42-
lum > 0.9 ? 0.45 : lum > 0.7 ? 0.88 : lum > 0.5 ? 0.92 : lum < 0.15 ? 3.5 : lum < 0.3 ? 2 : 1;
43-
const lightColor = adj(r, g, b, lightBgMul);
44-
const darkColor = adj(r, g, b, darkBgMul);
45-
const lightText = adj(r, g, b, textMul);
46-
const darkText = adj(r, g, b, darkBgMul);
47-
const lightLum = lum * lightBgMul;
48-
const darkLum = Math.min(1, lum * darkBgMul);
49-
const lightFg = lightLum > 0.5 ? '#0a0a0a' : '#fafafa';
50-
const darkFg = darkLum > 0.5 ? '#0a0a0a' : '#fafafa';
51-
const targetTintLum = 0.45;
52-
const tintMul = lum > 0 ? Math.max(1, targetTintLum / lum) : 6;
53-
const cardTint = adj(r, g, b, Math.min(tintMul, 10));
54-
return { lightColor, darkColor, lightFg, darkFg, lightText, darkText, cardTint };
55-
}
56-
5731
function createThemeInitializationScript(defaultTheme: 'light' | 'dark' | 'system'): string {
5832
return `(()=>{try{const e=document.documentElement,p=new URLSearchParams(location.search).get("appearance");let t=p==="light"||p==="dark"?p:"";if(!t){try{const s=localStorage.getItem("theme");if(s==="light"||s==="dark"||s==="system")t=s}catch{}}if(!t)t="${defaultTheme}";const r=t==="system"?(matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"):t;e.classList.remove("light","dark");e.classList.add(r);e.style.colorScheme=r}catch{}})();`;
5933
}
@@ -196,13 +170,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
196170
const { customHeadHtml, ...siteConfig } = readSiteConfig();
197171
const defaultTheme = siteConfig.theme ?? 'system';
198172

199-
const pc = siteConfig.primaryColor;
200-
const primaryCss = pc
201-
? (() => {
202-
const p = parsePrimary(pc);
203-
return `html{--color-primary:${p.lightColor}!important;--color-primary-foreground:${p.lightFg}!important;--primary-text:${p.lightText};--primary-card-tint:${p.cardTint}}html.dark{--color-primary:${p.darkColor}!important;--color-primary-foreground:${p.darkFg}!important;--primary-text:${p.darkText};--primary-card-tint:${p.cardTint}}@media(prefers-color-scheme:dark){html:not(.light){--color-primary:${p.darkColor}!important;--color-primary-foreground:${p.darkFg}!important;--primary-text:${p.darkText};--primary-card-tint:${p.cardTint}}}`;
204-
})()
205-
: '';
173+
const primaryCss = primaryThemeCss(siteConfig.primaryColor ?? '');
206174

207175
return (
208176
<html

‎packages/docs-ui/app/page.tsx‎

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type HomeConfig,
1010
} from '@/components/docs/site-config-provider';
1111
import { useProjectWatch } from '@/lib/use-project-watch';
12+
import { primaryThemeCss } from '@/lib/primary-theme';
1213

1314
const COLS = 45;
1415
const ROWS = 16;
@@ -111,44 +112,13 @@ export default function Home() {
111112
if (!data) return;
112113
if (data.home) setHome(data.home);
113114
if (data.primaryColor && /^#[0-9a-fA-F]{6}$/.test(data.primaryColor)) {
114-
const pc = data.primaryColor;
115-
const r = parseInt(pc.slice(1, 3), 16);
116-
const g = parseInt(pc.slice(3, 5), 16);
117-
const b = parseInt(pc.slice(5, 7), 16);
118-
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
119-
const a = (m: number) =>
120-
`rgb(${Math.min(255, Math.round(r * m))},${Math.min(255, Math.round(g * m))},${Math.min(255, Math.round(b * m))})`;
121-
const lbm = lum < 0.15 ? 1.8 : lum < 0.3 ? 1.3 : 1;
122-
const dbm = lum < 0.15 ? 4 : lum < 0.3 ? 2.2 : lum < 0.4 ? 1.4 : 1;
123-
const tm =
124-
lum > 0.9
125-
? 0.45
126-
: lum > 0.7
127-
? 0.88
128-
: lum > 0.5
129-
? 0.92
130-
: lum < 0.15
131-
? 3.5
132-
: lum < 0.3
133-
? 2
134-
: 1;
135-
const tintMul = lum > 0 ? Math.max(1, 0.45 / lum) : 6;
136-
const lightColor = a(lbm),
137-
darkColor = a(dbm),
138-
lightText = a(tm),
139-
darkText = a(dbm),
140-
cardTint = a(Math.min(tintMul, 10));
141-
const lightLum = lum * lbm,
142-
darkLum = Math.min(1, lum * dbm);
143-
const lightFg = lightLum > 0.5 ? '#0a0a0a' : '#fafafa';
144-
const darkFg = darkLum > 0.5 ? '#0a0a0a' : '#fafafa';
145115
let s = document.querySelector('style[data-primary]') as HTMLStyleElement;
146116
if (!s) {
147117
s = document.createElement('style');
148118
s.setAttribute('data-primary', '');
149119
document.body.prepend(s);
150120
}
151-
s.textContent = `html{--color-primary:${lightColor}!important;--color-primary-foreground:${lightFg}!important;--primary-text:${lightText};--primary-card-tint:${cardTint}}html.dark{--color-primary:${darkColor}!important;--color-primary-foreground:${darkFg}!important;--primary-text:${darkText};--primary-card-tint:${cardTint}}@media(prefers-color-scheme:dark){html:not(.light){--color-primary:${darkColor}!important;--color-primary-foreground:${darkFg}!important;--primary-text:${darkText};--primary-card-tint:${cardTint}}}`;
121+
s.textContent = primaryThemeCss(data.primaryColor);
152122
}
153123
})
154124
.catch(() => {});

‎packages/docs-ui/components/docs/docs-header.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ export function DocsHeader() {
379379
)}
380380
{!hasCustomLogo && (
381381
<span className="truncate text-lg font-semibold tracking-tight">
382-
{project || siteTitle || 'Cortex'}
382+
{siteTitle || project || 'Cortex'}
383383
</span>
384384
)}
385385
</Link>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
type Rgb = [number, number, number];
2+
3+
function luminance(color: Rgb): number {
4+
const linear = color.map((channel) => {
5+
const value = channel / 255;
6+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
7+
});
8+
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
9+
}
10+
11+
export function primaryPalette(hex: string) {
12+
const color: Rgb = [
13+
parseInt(hex.slice(1, 3), 16),
14+
parseInt(hex.slice(3, 5), 16),
15+
parseInt(hex.slice(5, 7), 16),
16+
];
17+
const brightness = (0.299 * color[0] + 0.587 * color[1] + 0.114 * color[2]) / 255;
18+
const adjusted = (multiplier: number) =>
19+
`rgb(${color.map((channel) => Math.min(255, Math.round(channel * multiplier))).join(',')})`;
20+
const backgroundLuminance = luminance(color);
21+
const whiteContrast = 1.05 / (backgroundLuminance + 0.05);
22+
const darkContrast = (backgroundLuminance + 0.05) / (luminance([10, 10, 10]) + 0.05);
23+
const foreground = whiteContrast >= darkContrast ? '#ffffff' : '#0a0a0a';
24+
const textMultiplier =
25+
brightness > 0.9
26+
? 0.45
27+
: brightness > 0.7
28+
? 0.88
29+
: brightness > 0.5
30+
? 0.92
31+
: brightness < 0.15
32+
? 3.5
33+
: brightness < 0.3
34+
? 2
35+
: 1;
36+
const darkTextMultiplier =
37+
brightness < 0.15 ? 4 : brightness < 0.3 ? 2.2 : brightness < 0.4 ? 1.4 : 1;
38+
const tintMultiplier = brightness > 0 ? Math.max(1, 0.45 / brightness) : 6;
39+
return {
40+
// Keep the configured fill in both themes. Brighten standalone accents separately.
41+
background: adjusted(1),
42+
foreground,
43+
lightText: adjusted(textMultiplier),
44+
darkText: adjusted(darkTextMultiplier),
45+
cardTint: adjusted(Math.min(tintMultiplier, 10)),
46+
};
47+
}
48+
49+
export function primaryThemeCss(hex: string): string {
50+
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return '';
51+
const p = primaryPalette(hex);
52+
const shared = `--color-primary:${p.background}!important;--color-primary-foreground:${p.foreground}!important;--primary-card-tint:${p.cardTint}`;
53+
return `html{${shared};--primary-text:${p.lightText}}html.dark{${shared};--primary-text:${p.darkText}}@media(prefers-color-scheme:dark){html:not(.light){${shared};--primary-text:${p.darkText}}}`;
54+
}

0 commit comments

Comments
 (0)