Skip to content

Commit a65de5e

Browse files
committed
fix(app): harden dual-theme contrast
Separate filled primary controls from brand accents and add Axe coverage for light and dark themes. Fixes #107
1 parent 44c5a11 commit a65de5e

14 files changed

Lines changed: 101 additions & 20 deletions

File tree

app/e2e/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ Read-only (this directory):
7777
those against a throwaway project rather than your live one.
7878
- **`responsive.e2e.ts`** — screenshots every route across a desktop/tablet/phone
7979
matrix and asserts no route scrolls sideways at tablet/phone widths.
80+
- **`theme.e2e.ts`** — verifies light/dark preference switching and persistence, then runs
81+
Axe's rendered color-contrast rule across representative routes in both themes.
8082

8183
Mutating (`mut/`, against the isolated server):
8284

app/e2e/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"report": "playwright show-report"
1010
},
1111
"devDependencies": {
12+
"@axe-core/playwright": "^4.12.1",
1213
"@playwright/test": "^1.61.1",
1314
"@types/node": "^22.0.0"
1415
}

app/e2e/theme.e2e.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import AxeBuilder from "@axe-core/playwright";
12
import { expect, test } from "@playwright/test";
23

34
/**
45
* Theme toggle (#107): Settings → Display → Theme flips the `.dark` class and
56
* `color-scheme` on <html> and persists across a reload. This exercises the
6-
* MECHANISM; the pixel-level light-mode contrast pass is a human visual review.
7+
* switching mechanism; the rendered contrast contract is covered below.
78
*/
89
test("theme toggle flips the dark class and persists", async ({ page }) => {
910
await page.goto("/settings?tab=planning");
@@ -28,3 +29,67 @@ test("theme toggle flips the dark class and persists", async ({ page }) => {
2829
await page.getByRole("option", { name: "Dark" }).click();
2930
await expect(html).toHaveClass(/dark/);
3031
});
32+
33+
const CONTRAST_ROUTES = ["/", "/block", "/factory", "/assistant", "/settings"] as const;
34+
35+
for (const theme of ["light", "dark"] as const) {
36+
test(`${theme} theme keeps representative routes contrast-safe`, async ({ page }) => {
37+
await page.addInitScript((preference) => {
38+
localStorage.setItem("pyops.theme", preference);
39+
}, theme);
40+
41+
const failures: string[] = [];
42+
for (const route of CONTRAST_ROUTES) {
43+
await page.goto(route, { waitUntil: "domcontentloaded" });
44+
await expect(page.locator("nav").getByRole("link", { name: "PyOps" })).toBeVisible();
45+
await page.waitForTimeout(250);
46+
47+
await page.evaluate(() => {
48+
const fixture = document.createElement("div");
49+
fixture.setAttribute("data-theme-contrast-fixture", "");
50+
Object.assign(fixture.style, {
51+
position: "fixed",
52+
top: "40px",
53+
left: "0",
54+
zIndex: "99999",
55+
fontSize: "14px",
56+
fontWeight: "400",
57+
});
58+
59+
const pairs = [
60+
["foreground", "var(--foreground)", "var(--background)"],
61+
["muted", "var(--muted-foreground)", "var(--background)"],
62+
["primary", "var(--primary)", "var(--background)"],
63+
["primary action", "var(--primary-foreground)", "var(--primary-solid)"],
64+
...["success", "warning", "info", "surplus"].flatMap((token) => [
65+
[token, `var(--${token})`, "var(--background)"],
66+
[
67+
`${token} tint`,
68+
`var(--${token})`,
69+
`color-mix(in oklab, var(--${token}) 10%, var(--background))`,
70+
],
71+
]),
72+
];
73+
74+
for (const [label, color, backgroundColor] of pairs) {
75+
const sample = document.createElement("span");
76+
sample.textContent = label;
77+
Object.assign(sample.style, { display: "block", color, backgroundColor });
78+
fixture.append(sample);
79+
}
80+
document.body.append(fixture);
81+
});
82+
83+
const result = await new AxeBuilder({ page }).withRules(["color-contrast"]).analyze();
84+
for (const violation of result.violations) {
85+
for (const node of violation.nodes) {
86+
failures.push(
87+
`${route} ${node.target.join(" ")}: ${node.failureSummary ?? violation.help}`,
88+
);
89+
}
90+
}
91+
}
92+
93+
expect(failures, failures.join("\n\n")).toEqual([]);
94+
});
95+
}

app/src/components/block/temporary-campaign-controls.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export function TemporaryCampaignControls({
9595
onKeyDown={(event) => {
9696
if (event.key === "Enter") event.currentTarget.blur();
9797
}}
98-
className="h-6 w-11 border-0 bg-transparent px-1 text-right shadow-none focus-visible:ring-0 dark:bg-transparent"
98+
className="h-6 w-11 border-0 bg-transparent px-1 text-right shadow-none focus-visible:ring-0"
9999
/>
100100
<TimeUnitControl
101101
unit={unit}

app/src/components/ui/badge.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const badgeVariants = cva(
99
{
1010
variants: {
1111
variant: {
12-
default: "border-transparent bg-primary text-primary-foreground",
12+
default: "border-transparent bg-primary-solid text-primary-foreground",
1313
secondary: "border-transparent bg-muted text-muted-foreground",
1414
destructive: "border-transparent bg-destructive/15 text-destructive",
1515
outline: "text-foreground",

app/src/components/ui/button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const buttonVariants = cva(
99
{
1010
variants: {
1111
variant: {
12-
default: "bg-primary text-primary-foreground hover:bg-primary/80",
12+
default: "bg-primary-solid text-primary-foreground hover:bg-primary-solid/90",
1313
outline:
1414
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
1515
secondary:

app/src/components/ui/checkbox.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
1313
<CheckboxPrimitive.Root
1414
data-slot="checkbox"
1515
className={cn(
16-
"peer size-4 shrink-0 rounded-none border border-input bg-transparent transition-colors outline-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground data-disabled:cursor-not-allowed data-disabled:opacity-50 dark:bg-input/30 dark:data-checked:bg-primary dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
16+
"peer size-4 shrink-0 rounded-none border border-input bg-transparent transition-colors outline-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 data-checked:border-primary-solid data-checked:bg-primary-solid data-checked:text-primary-foreground data-disabled:cursor-not-allowed data-disabled:opacity-50 dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
1717
className,
1818
)}
1919
{...props}

app/src/components/ui/segmented.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ function Segmented<T extends string>({
4545
"flex items-center justify-center gap-1.5 text-sm font-medium whitespace-nowrap transition-colors outline-none select-none focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
4646
size === "sm" ? "h-7 px-2.5" : "h-8 px-3",
4747
value === o.value
48-
? "bg-primary text-primary-foreground"
48+
? "bg-primary-solid text-primary-foreground"
4949
: "text-muted-foreground hover:bg-muted hover:text-foreground",
5050
)}
5151
>

app/src/components/ui/switch.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ function Switch({
1717
data-slot="switch"
1818
data-size={size}
1919
className={cn(
20-
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
20+
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary-solid data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
2121
className,
2222
)}
2323
{...props}
2424
>
2525
<SwitchPrimitive.Thumb
2626
data-slot="switch-thumb"
27-
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
27+
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
2828
/>
2929
</SwitchPrimitive.Root>
3030
);

app/src/lib/modules-modal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ function BeaconMatrix({
439439
locked
440440
? "cursor-not-allowed border border-destructive bg-destructive/20 font-semibold text-destructive ring-2 ring-destructive/70"
441441
: cur
442-
? "bg-primary text-primary-foreground"
442+
? "bg-primary-solid text-primary-foreground"
443443
: "bg-muted/40 hover:bg-accent"
444444
}`}
445445
>

0 commit comments

Comments
 (0)