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
18 changes: 18 additions & 0 deletions e2e/core/core-light-theme-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 });
Expand Down
19 changes: 19 additions & 0 deletions e2e/helpers/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down Expand Up @@ -141,6 +143,9 @@ export async function getThemeChromeMetrics(
const projectTrigger = document.querySelector<HTMLElement>(selectors.projectTrigger);
const quickRunInput = document.querySelector<HTMLInputElement>(selectors.quickRunInput);
const worktreeCard = document.querySelector<HTMLElement>(selectors.worktreeCard);
const sidebar = document.querySelector<HTMLElement>(selectors.sidebar);
const gridPanel = document.querySelector<HTMLElement>(selectors.gridPanel);
const gridContainer = document.querySelector<HTMLElement>(selectors.gridContainer);

if (!projectTrigger || !quickRunInput || !worktreeCard) {
throw new Error("Required theme smoke selectors were not found");
Expand All @@ -167,20 +172,34 @@ 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),
projectBackground
),
quickRunFieldBorderContrast: contrastRatio(fieldBorderColor, quickRunBackground),
worktreeSectionContrast: contrastRatio(sectionBackground, cardBackground),
sidebarVsCanvasContrast: contrastRatio(sidebarBackground, rootBackground),
panelVsGridContrast: contrastRatio(panelBackground, gridContainerBackground),
};
},
{
projectTrigger: SEL.toolbar.projectSwitcherTrigger,
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",
}
);
}
76 changes: 64 additions & 12 deletions shared/theme/__tests__/themes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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)");
});
});
Expand Down Expand Up @@ -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")!;

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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", () => {
Expand Down
26 changes: 15 additions & 11 deletions shared/theme/themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
};
Expand Down