diff --git a/e2e/core/core-light-theme-smoke.spec.ts b/e2e/core/core-light-theme-smoke.spec.ts index 3e60f5ff5..76b57c7fd 100644 --- a/e2e/core/core-light-theme-smoke.spec.ts +++ b/e2e/core/core-light-theme-smoke.spec.ts @@ -47,6 +47,12 @@ test.describe.serial("Core: Light Theme Smoke", () => { window.locator(SEL.toolbar.projectSwitcherTrigger), `${schemeId}: project switcher should still show the active project` ).toContainText(PROJECT_NAME); + expect + .soft( + metrics.projectTitleContrast, + `${schemeId}: project title text should meet WCAG AA contrast` + ) + .toBeGreaterThanOrEqual(4.5); expect .soft( metrics.quickRunFieldBorderContrast, @@ -59,6 +65,18 @@ test.describe.serial("Core: Light Theme Smoke", () => { `${schemeId}: worktree sections should remain visually separated` ) .toBeGreaterThanOrEqual(1.08); + expect + .soft( + metrics.sidebarVsCanvasContrast, + `${schemeId}: sidebar should be visually separated from canvas` + ) + .toBeGreaterThanOrEqual(1.08); + expect + .soft( + metrics.panelVsGridContrast, + `${schemeId}: panel background should differ from grid background` + ) + .toBeGreaterThanOrEqual(1.05); const screenshotPath = testInfo.outputPath(`light-theme-${schemeId}.png`); await window.screenshot({ path: screenshotPath, fullPage: true }); diff --git a/e2e/helpers/theme.ts b/e2e/helpers/theme.ts index 36a139271..8c1b596a6 100644 --- a/e2e/helpers/theme.ts +++ b/e2e/helpers/theme.ts @@ -5,6 +5,8 @@ export interface ThemeChromeMetrics { projectTitleContrast: number; quickRunFieldBorderContrast: number; worktreeSectionContrast: number; + sidebarVsCanvasContrast: number; + panelVsGridContrast: number; } export async function setAppTheme(page: Page, schemeId: string): Promise { @@ -141,6 +143,9 @@ export async function getThemeChromeMetrics( const projectTrigger = document.querySelector(selectors.projectTrigger); const quickRunInput = document.querySelector(selectors.quickRunInput); const worktreeCard = document.querySelector(selectors.worktreeCard); + const sidebar = document.querySelector(selectors.sidebar); + const gridPanel = document.querySelector(selectors.gridPanel); + const gridContainer = document.querySelector(selectors.gridContainer); if (!projectTrigger || !quickRunInput || !worktreeCard) { throw new Error("Required theme smoke selectors were not found"); @@ -167,6 +172,15 @@ export async function getThemeChromeMetrics( const cardBackground = resolveEffectiveBackground(worktreeCard); const sectionBackground = resolveEffectiveBackground(detailsSection); + const rootBackground = resolveEffectiveBackground(document.body); + const sidebarBackground = sidebar ? resolveEffectiveBackground(sidebar) : rootBackground; + const gridContainerBackground = gridContainer + ? resolveEffectiveBackground(gridContainer) + : rootBackground; + const panelBackground = gridPanel + ? resolveEffectiveBackground(gridPanel) + : gridContainerBackground; + return { projectTitleContrast: contrastRatio( parseColor(getComputedStyle(projectTitle).color), @@ -174,6 +188,8 @@ export async function getThemeChromeMetrics( ), quickRunFieldBorderContrast: contrastRatio(fieldBorderColor, quickRunBackground), worktreeSectionContrast: contrastRatio(sectionBackground, cardBackground), + sidebarVsCanvasContrast: contrastRatio(sidebarBackground, rootBackground), + panelVsGridContrast: contrastRatio(panelBackground, gridContainerBackground), }; }, { @@ -181,6 +197,9 @@ export async function getThemeChromeMetrics( quickRunInput: '[aria-label="Command input"]', worktreeCard: SEL.worktree.card(branch), projectName: options.projectName, + sidebar: 'aside[aria-label="Sidebar"]', + gridPanel: SEL.panel.gridPanel, + gridContainer: "#terminal-grid", } ); } diff --git a/shared/theme/__tests__/themes.test.ts b/shared/theme/__tests__/themes.test.ts index 738d3f2bd..e3e14510b 100644 --- a/shared/theme/__tests__/themes.test.ts +++ b/shared/theme/__tests__/themes.test.ts @@ -86,17 +86,17 @@ describe("createCanopyTokens — light mode derived defaults", () => { const lightTokens = createCanopyTokens("light", REQUIRED_TOKENS); it("derives border defaults from the theme's foreground ink for light mode", () => { - expect(lightTokens["border-subtle"]).toBe("rgba(26, 26, 26, 0.06)"); - expect(lightTokens["border-strong"]).toBe("rgba(26, 26, 26, 0.12)"); + expect(lightTokens["border-subtle"]).toBe("rgba(26, 26, 26, 0.12)"); + expect(lightTokens["border-strong"]).toBe("rgba(26, 26, 26, 0.2)"); expect(lightTokens["border-divider"]).toBe("rgba(26, 26, 26, 0.08)"); }); it("derives overlay defaults from the theme's foreground ink for light mode", () => { expect(lightTokens["overlay-subtle"]).toBe("rgba(26, 26, 26, 0.04)"); - expect(lightTokens["overlay-soft"]).toBe("rgba(26, 26, 26, 0.06)"); - expect(lightTokens["overlay-medium"]).toBe("rgba(26, 26, 26, 0.08)"); - expect(lightTokens["overlay-strong"]).toBe("rgba(26, 26, 26, 0.1)"); - expect(lightTokens["overlay-emphasis"]).toBe("rgba(26, 26, 26, 0.14)"); + expect(lightTokens["overlay-soft"]).toBe("rgba(26, 26, 26, 0.08)"); + expect(lightTokens["overlay-medium"]).toBe("rgba(26, 26, 26, 0.12)"); + expect(lightTokens["overlay-strong"]).toBe("rgba(26, 26, 26, 0.16)"); + expect(lightTokens["overlay-emphasis"]).toBe("rgba(26, 26, 26, 0.2)"); }); it("sets lighter scrim defaults for light mode", () => { @@ -106,12 +106,12 @@ describe("createCanopyTokens — light mode derived defaults", () => { }); it("derives focus-ring from the theme's foreground ink for light mode", () => { - expect(lightTokens["focus-ring"]).toBe("rgba(26, 26, 26, 0.15)"); + expect(lightTokens["focus-ring"]).toBe("rgba(26, 26, 26, 0.2)"); }); it("keeps the default Bondi overlays warm instead of neutral black", () => { const bondi = BUILT_IN_APP_SCHEMES.find((scheme) => scheme.id === "bondi")!; - expect(bondi.tokens["overlay-soft"]).toBe("rgba(27, 54, 38, 0.06)"); + expect(bondi.tokens["overlay-soft"]).toBe("rgba(27, 54, 38, 0.08)"); expect(bondi.tokens["border-divider"]).toBe("rgba(27, 54, 38, 0.08)"); }); }); @@ -175,6 +175,34 @@ describe("createCanopyTokens — caller overrides win via spread", () => { }); }); +describe("createCanopyTokens — accent-soft/muted branch by type", () => { + it("uses higher alpha for dark accent-soft/muted", () => { + const darkTokens = createCanopyTokens("dark", REQUIRED_TOKENS); + expect(darkTokens["accent-soft"]).toBe("rgba(63, 147, 102, 0.18)"); + expect(darkTokens["accent-muted"]).toBe("rgba(63, 147, 102, 0.3)"); + }); + + it("uses lower alpha for light accent-soft/muted", () => { + const lightTokens = createCanopyTokens("light", REQUIRED_TOKENS); + expect(lightTokens["accent-soft"]).toBe("rgba(63, 147, 102, 0.12)"); + expect(lightTokens["accent-muted"]).toBe("rgba(63, 147, 102, 0.2)"); + }); +}); + +describe("createCanopyTokens — terminal fallbacks branch by type", () => { + it("dark: terminal-black = surface-canvas, terminal-white = text-primary", () => { + const darkTokens = createCanopyTokens("dark", REQUIRED_TOKENS); + expect(darkTokens["terminal-black"]).toBe(REQUIRED_TOKENS["surface-canvas"]); + expect(darkTokens["terminal-white"]).toBe(REQUIRED_TOKENS["text-primary"]); + }); + + it("light: terminal-black = text-primary, terminal-white = surface-canvas", () => { + const lightTokens = createCanopyTokens("light", REQUIRED_TOKENS); + expect(lightTokens["terminal-black"]).toBe(REQUIRED_TOKENS["text-primary"]); + expect(lightTokens["terminal-white"]).toBe(REQUIRED_TOKENS["surface-canvas"]); + }); +}); + describe("built-in schemes — Daintree has explicit category colors", () => { const canopy = BUILT_IN_APP_SCHEMES.find((s) => s.id === "daintree")!; @@ -542,6 +570,30 @@ describe("Hokkaido built-in scheme", () => { }); }); +describe("built-in schemes — Svalbard light terminal fallbacks", () => { + const svalbard = BUILT_IN_APP_SCHEMES.find((s) => s.id === "svalbard")!; + + it("auto-derives terminal-black from text-primary for light themes", () => { + expect(svalbard.tokens["terminal-black"]).toBe(svalbard.tokens["text-primary"]); + }); + + it("auto-derives terminal-white from surface-canvas for light themes", () => { + expect(svalbard.tokens["terminal-white"]).toBe(svalbard.tokens["surface-canvas"]); + }); +}); + +describe("built-in schemes — Hokkaido light terminal fallbacks", () => { + const hokkaido = BUILT_IN_APP_SCHEMES.find((s) => s.id === "hokkaido")!; + + it("auto-derives terminal-black from text-primary for light themes", () => { + expect(hokkaido.tokens["terminal-black"]).toBe(hokkaido.tokens["text-primary"]); + }); + + it("auto-derives terminal-white from surface-canvas for light themes", () => { + expect(hokkaido.tokens["terminal-white"]).toBe(hokkaido.tokens["surface-canvas"]); + }); +}); + describe("normalizeAppColorScheme", () => { it("uses a light fallback base for partial light themes", () => { const scheme = normalizeAppColorScheme({ @@ -589,12 +641,12 @@ describe("built-in schemes — Atacama light theme", () => { expect(atacama.tokens["terminal-bright-white"]).toBe("#1A1210"); }); - it("auto-derives terminal-black from surface-canvas", () => { - expect(atacama.tokens["terminal-black"]).toBe(atacama.tokens["surface-canvas"]); + it("auto-derives terminal-black from text-primary for light themes", () => { + expect(atacama.tokens["terminal-black"]).toBe(atacama.tokens["text-primary"]); }); - it("auto-derives terminal-white from text-primary", () => { - expect(atacama.tokens["terminal-white"]).toBe(atacama.tokens["text-primary"]); + it("auto-derives terminal-white from surface-canvas for light themes", () => { + expect(atacama.tokens["terminal-white"]).toBe(atacama.tokens["surface-canvas"]); }); it("auto-derives terminal-bright-black from activity-idle", () => { diff --git a/shared/theme/themes.ts b/shared/theme/themes.ts index d10361c0e..dc2ba4486 100644 --- a/shared/theme/themes.ts +++ b/shared/theme/themes.ts @@ -70,8 +70,10 @@ export function createCanopyTokens( const dark = type === "dark"; const lightInk = tokens["text-primary"]; const overlayTone = dark ? "#ffffff" : lightInk; - const accentSoft = tokens["accent-soft"] ?? withAlpha(tokens["accent-primary"], 0.18); - const accentMuted = tokens["accent-muted"] ?? withAlpha(tokens["accent-primary"], 0.3); + const accentSoft = + tokens["accent-soft"] ?? withAlpha(tokens["accent-primary"], dark ? 0.18 : 0.12); + const accentMuted = + tokens["accent-muted"] ?? withAlpha(tokens["accent-primary"], dark ? 0.3 : 0.2); return { ...GITHUB_TOKENS, @@ -87,25 +89,27 @@ export function createCanopyTokens( "category-pink": tokens["category-pink"] ?? "oklch(0.72 0.13 340)", "category-violet": tokens["category-violet"] ?? "oklch(0.7 0.13 295)", "category-slate": tokens["category-slate"] ?? "oklch(0.65 0.04 240)", - "border-subtle": tokens["border-subtle"] ?? withAlpha(overlayTone, dark ? 0.08 : 0.06), - "border-strong": tokens["border-strong"] ?? withAlpha(overlayTone, dark ? 0.14 : 0.12), + "border-subtle": tokens["border-subtle"] ?? withAlpha(overlayTone, dark ? 0.08 : 0.12), + "border-strong": tokens["border-strong"] ?? withAlpha(overlayTone, dark ? 0.14 : 0.2), "border-divider": tokens["border-divider"] ?? withAlpha(overlayTone, dark ? 0.05 : 0.08), "accent-foreground": tokens["accent-foreground"] ?? tokens["text-inverse"], "accent-soft": accentSoft, "accent-muted": accentMuted, - "focus-ring": tokens["focus-ring"] ?? withAlpha(overlayTone, dark ? 0.18 : 0.15), + "focus-ring": tokens["focus-ring"] ?? withAlpha(overlayTone, dark ? 0.18 : 0.2), "overlay-subtle": tokens["overlay-subtle"] ?? withAlpha(overlayTone, dark ? 0.02 : 0.04), - "overlay-soft": tokens["overlay-soft"] ?? withAlpha(overlayTone, dark ? 0.03 : 0.06), - "overlay-medium": tokens["overlay-medium"] ?? withAlpha(overlayTone, dark ? 0.04 : 0.08), - "overlay-strong": tokens["overlay-strong"] ?? withAlpha(overlayTone, dark ? 0.06 : 0.1), - "overlay-emphasis": tokens["overlay-emphasis"] ?? withAlpha(overlayTone, dark ? 0.1 : 0.14), + "overlay-soft": tokens["overlay-soft"] ?? withAlpha(overlayTone, dark ? 0.03 : 0.08), + "overlay-medium": tokens["overlay-medium"] ?? withAlpha(overlayTone, dark ? 0.04 : 0.12), + "overlay-strong": tokens["overlay-strong"] ?? withAlpha(overlayTone, dark ? 0.06 : 0.16), + "overlay-emphasis": tokens["overlay-emphasis"] ?? withAlpha(overlayTone, dark ? 0.1 : 0.2), "scrim-soft": tokens["scrim-soft"] ?? (dark ? "rgba(0, 0, 0, 0.2)" : "rgba(0, 0, 0, 0.12)"), "scrim-medium": tokens["scrim-medium"] ?? (dark ? "rgba(0, 0, 0, 0.45)" : "rgba(0, 0, 0, 0.30)"), "scrim-strong": tokens["scrim-strong"] ?? (dark ? "rgba(0, 0, 0, 0.62)" : "rgba(0, 0, 0, 0.45)"), - "terminal-black": tokens["terminal-black"] ?? tokens["surface-canvas"], - "terminal-white": tokens["terminal-white"] ?? tokens["text-primary"], + "terminal-black": + tokens["terminal-black"] ?? (dark ? tokens["surface-canvas"] : tokens["text-primary"]), + "terminal-white": + tokens["terminal-white"] ?? (dark ? tokens["text-primary"] : tokens["surface-canvas"]), "terminal-bright-black": tokens["terminal-bright-black"] ?? tokens["activity-idle"], ...tokens, };