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
23 changes: 23 additions & 0 deletions e2e/docs-ui-static.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,29 @@ test.describe('Cloudflare Static Assets export', () => {
await expect(page.locator('.cortex-cookie-settings-button')).toHaveCount(0);
});

for (const appearance of ['light', 'dark']) {
for (const [color, background, foreground] of [
['#c2410c', 'rgb(194, 65, 12)', 'rgb(255, 255, 255)'],
['#2563eb', 'rgb(37, 99, 235)', 'rgb(255, 255, 255)'],
['#7c3aed', 'rgb(124, 58, 237)', 'rgb(255, 255, 255)'],
['#ffffff', 'rgb(255, 255, 255)', 'rgb(10, 10, 10)'],
]) {
test(`adapts the homepage button text to ${color} in ${appearance} mode`, async ({
page,
}) => {
await page.route('**/api/config*', async (route) => {
const response = await route.fetch();
const config = await response.json();
await route.fulfill({ json: { ...config, primaryColor: color } });
});
await page.goto(`/?appearance=${appearance}`);
const button = page.getByRole('link', { name: 'Getting Started', exact: true });
await expect(button).toHaveCSS('background-color', background);
await expect(button).toHaveCSS('color', foreground);
});
}
}

test('supports client navigation between generated documentation pages', async ({ page }) => {
await page.goto('/docs/quickstart');
await expect(page).toHaveTitle('Petstore Docs');
Expand Down
28 changes: 28 additions & 0 deletions packages/docs-ui/__tests__/primary-theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { primaryPalette, primaryThemeCss } from '../lib/primary-theme';

describe('primary button contrast', () => {
it.each(['#c2410c', '#2563eb', '#7c3aed', '#000000', '#0000ff'])(
'uses white text on %s',
(color) => {
expect(primaryPalette(color).foreground).toBe('#ffffff');
},
);

it.each(['#ffffff', '#ffff00', '#00ff00', '#f97316', '#ff0000'])(
'uses dark text on a light fill (%s)',
(color) => {
expect(primaryPalette(color).foreground).toBe('#0a0a0a');
},
);

it('keeps the chosen button fill instead of brightening it in dark mode', () => {
expect(primaryPalette('#2563eb').background).toBe('rgb(37,99,235)');
expect(primaryThemeCss('#2563eb').match(/--color-primary:rgb\(37,99,235\)/g)).toHaveLength(3);
});

it('ignores invalid colors before producing CSS', () => {
expect(primaryThemeCss('red')).toBe('');
expect(primaryThemeCss('#fff; color:red')).toBe('');
});
});
36 changes: 2 additions & 34 deletions packages/docs-ui/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { SearchProvider } from '@/components/docs/search-provider';
import { GoogleAnalytics } from '@/components/docs/google-analytics';
import { sanitizeSvg } from '@/lib/sanitize-svg';
import { primaryThemeCss } from '@/lib/primary-theme';

interface LoadedSiteConfig extends SiteConfig {
customHeadHtml?: string;
Expand All @@ -27,33 +28,6 @@ function emptySiteConfig(): LoadedSiteConfig {
};
}

function adj(r: number, g: number, b: number, mul: number) {
return `rgb(${Math.min(255, Math.round(r * mul))},${Math.min(255, Math.round(g * mul))},${Math.min(255, Math.round(b * mul))})`;
}

function parsePrimary(hex: string) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
const lightBgMul = lum < 0.15 ? 1.8 : lum < 0.3 ? 1.3 : 1;
const darkBgMul = lum < 0.15 ? 4 : lum < 0.3 ? 2.2 : lum < 0.4 ? 1.4 : 1;
const textMul =
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;
const lightColor = adj(r, g, b, lightBgMul);
const darkColor = adj(r, g, b, darkBgMul);
const lightText = adj(r, g, b, textMul);
const darkText = adj(r, g, b, darkBgMul);
const lightLum = lum * lightBgMul;
const darkLum = Math.min(1, lum * darkBgMul);
const lightFg = lightLum > 0.5 ? '#0a0a0a' : '#fafafa';
const darkFg = darkLum > 0.5 ? '#0a0a0a' : '#fafafa';
const targetTintLum = 0.45;
const tintMul = lum > 0 ? Math.max(1, targetTintLum / lum) : 6;
const cardTint = adj(r, g, b, Math.min(tintMul, 10));
return { lightColor, darkColor, lightFg, darkFg, lightText, darkText, cardTint };
}

function createThemeInitializationScript(defaultTheme: 'light' | 'dark' | 'system'): string {
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{}})();`;
}
Expand Down Expand Up @@ -196,13 +170,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
const { customHeadHtml, ...siteConfig } = readSiteConfig();
const defaultTheme = siteConfig.theme ?? 'system';

const pc = siteConfig.primaryColor;
const primaryCss = pc
? (() => {
const p = parsePrimary(pc);
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}}}`;
})()
: '';
const primaryCss = primaryThemeCss(siteConfig.primaryColor ?? '');

return (
<html
Expand Down
34 changes: 2 additions & 32 deletions packages/docs-ui/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type HomeConfig,
} from '@/components/docs/site-config-provider';
import { useProjectWatch } from '@/lib/use-project-watch';
import { primaryThemeCss } from '@/lib/primary-theme';

const COLS = 45;
const ROWS = 16;
Expand Down Expand Up @@ -111,44 +112,13 @@ export default function Home() {
if (!data) return;
if (data.home) setHome(data.home);
if (data.primaryColor && /^#[0-9a-fA-F]{6}$/.test(data.primaryColor)) {
const pc = data.primaryColor;
const r = parseInt(pc.slice(1, 3), 16);
const g = parseInt(pc.slice(3, 5), 16);
const b = parseInt(pc.slice(5, 7), 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
const a = (m: number) =>
`rgb(${Math.min(255, Math.round(r * m))},${Math.min(255, Math.round(g * m))},${Math.min(255, Math.round(b * m))})`;
const lbm = lum < 0.15 ? 1.8 : lum < 0.3 ? 1.3 : 1;
const dbm = lum < 0.15 ? 4 : lum < 0.3 ? 2.2 : lum < 0.4 ? 1.4 : 1;
const tm =
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;
const tintMul = lum > 0 ? Math.max(1, 0.45 / lum) : 6;
const lightColor = a(lbm),
darkColor = a(dbm),
lightText = a(tm),
darkText = a(dbm),
cardTint = a(Math.min(tintMul, 10));
const lightLum = lum * lbm,
darkLum = Math.min(1, lum * dbm);
const lightFg = lightLum > 0.5 ? '#0a0a0a' : '#fafafa';
const darkFg = darkLum > 0.5 ? '#0a0a0a' : '#fafafa';
let s = document.querySelector('style[data-primary]') as HTMLStyleElement;
if (!s) {
s = document.createElement('style');
s.setAttribute('data-primary', '');
document.body.prepend(s);
}
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}}}`;
s.textContent = primaryThemeCss(data.primaryColor);
}
})
.catch(() => {});
Expand Down
2 changes: 1 addition & 1 deletion packages/docs-ui/components/docs/docs-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ export function DocsHeader() {
)}
{!hasCustomLogo && (
<span className="truncate text-lg font-semibold tracking-tight">
{project || siteTitle || 'Cortex'}
{siteTitle || project || 'Cortex'}
</span>
)}
</Link>
Expand Down
54 changes: 54 additions & 0 deletions packages/docs-ui/lib/primary-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
type Rgb = [number, number, number];

function luminance(color: Rgb): number {
const linear = color.map((channel) => {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}

export function primaryPalette(hex: string) {
const color: Rgb = [
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16),
];
const brightness = (0.299 * color[0] + 0.587 * color[1] + 0.114 * color[2]) / 255;
const adjusted = (multiplier: number) =>
`rgb(${color.map((channel) => Math.min(255, Math.round(channel * multiplier))).join(',')})`;
const backgroundLuminance = luminance(color);
const whiteContrast = 1.05 / (backgroundLuminance + 0.05);
const darkContrast = (backgroundLuminance + 0.05) / (luminance([10, 10, 10]) + 0.05);
const foreground = whiteContrast >= darkContrast ? '#ffffff' : '#0a0a0a';
const textMultiplier =
brightness > 0.9
? 0.45
: brightness > 0.7
? 0.88
: brightness > 0.5
? 0.92
: brightness < 0.15
? 3.5
: brightness < 0.3
? 2
: 1;
const darkTextMultiplier =
brightness < 0.15 ? 4 : brightness < 0.3 ? 2.2 : brightness < 0.4 ? 1.4 : 1;
const tintMultiplier = brightness > 0 ? Math.max(1, 0.45 / brightness) : 6;
return {
// Keep the configured fill in both themes. Brighten standalone accents separately.
background: adjusted(1),
foreground,
lightText: adjusted(textMultiplier),
darkText: adjusted(darkTextMultiplier),
cardTint: adjusted(Math.min(tintMultiplier, 10)),
};
}

export function primaryThemeCss(hex: string): string {
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return '';
const p = primaryPalette(hex);
const shared = `--color-primary:${p.background}!important;--color-primary-foreground:${p.foreground}!important;--primary-card-tint:${p.cardTint}`;
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}}}`;
}
Loading