From f91a8974381ef307cb8d46e8c7ce25b280621947 Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 13:32:11 -0500 Subject: [PATCH] fix: separate filled action colors from dark-mode accent text --- docs/design/BOARD-COLOR-SYSTEM.md | 10 +++ scripts/native-ui/contrast.mjs | 72 +++++++++++++++++++ scripts/native-ui/run.mjs | 13 ++++ web/src/__tests__/ui-vocabulary.test.tsx | 13 ++++ web/src/components/chat/FloatingChat.tsx | 2 +- .../digest/OperationsDigestPage.tsx | 5 +- web/src/components/drift/DriftMonitor.tsx | 3 +- web/src/components/feedback/FeedbackPanel.tsx | 2 +- web/src/components/layout/CommandPalette.tsx | 2 +- .../components/layout/DesktopLeftSidebar.tsx | 2 +- web/src/components/shared/ErrorFallback.tsx | 2 +- web/src/components/shared/SkipToContent.tsx | 2 +- web/src/components/task/TaskCard.tsx | 2 +- web/src/components/ui/badge.tsx | 2 +- web/src/components/ui/button.tsx | 2 +- web/src/globals.css | 8 ++- web/src/theme/mantine-theme.ts | 15 +++- 17 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 scripts/native-ui/contrast.mjs diff --git a/docs/design/BOARD-COLOR-SYSTEM.md b/docs/design/BOARD-COLOR-SYSTEM.md index 95ea54e2..815f7144 100644 --- a/docs/design/BOARD-COLOR-SYSTEM.md +++ b/docs/design/BOARD-COLOR-SYSTEM.md @@ -39,3 +39,13 @@ Identity tokens are `neutral`, `violet`, `cyan`, `orange`, `emerald`, `rose`, `a ## Responsive and accessibility behavior The plate and stamp stay compact in both board densities. Columns retain the board's existing responsive layout, while card metadata continues to wrap on narrow surfaces. Status glyphs, labels, counts, signal text, focus rings, and border changes preserve meaning in grayscale and common color-vision-deficiency conditions. Essential text and controls continue to use the established foreground and focus tokens; semantic color is supplemental. + +### Filled action contrast + +Filled controls use `--primary-action` with `--primary-foreground`, and +`--primary-action-hover` for hover. These are separate from the brighter `--primary` +accent used for text and focus in dark mode. Mantine filled Veritas controls and +Tailwind filled selections share this pair. Do not use opacity to lighten a +filled control with small white text. The native route gate measures normal, +hover and focus text contrast on shared actions, Drift filters and populated +Operations task identifiers in both themes. diff --git a/scripts/native-ui/contrast.mjs b/scripts/native-ui/contrast.mjs new file mode 100644 index 00000000..22d2089a --- /dev/null +++ b/scripts/native-ui/contrast.mjs @@ -0,0 +1,72 @@ +/* global document, getComputedStyle */ +import assert from 'node:assert/strict'; +import { expect } from '@playwright/test'; + +export async function measureTextContrast(locator) { + return locator.evaluate((element) => { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + const context = canvas.getContext('2d', { willReadFrequently: true }); + const rgba = (color) => { + context.clearRect(0, 0, 1, 1); + context.fillStyle = color; + context.fillRect(0, 0, 1, 1); + return [...context.getImageData(0, 0, 1, 1).data]; + }; + const blend = (front, back) => + front + .slice(0, 3) + .map((value, index) => (value * front[3]) / 255 + back[index] * (1 - front[3] / 255)); + const ancestors = []; + for (let node = element; node; node = node.parentElement) ancestors.unshift(node); + let background = [255, 255, 255]; + for (const node of ancestors) + background = blend(rgba(getComputedStyle(node).backgroundColor), background); + const style = getComputedStyle(element); + const foreground = blend(rgba(style.color), background); + const luminance = (rgb) => + rgb + .map((value) => { + const v = value / 255; + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }) + .reduce((sum, value, index) => sum + value * [0.2126, 0.7152, 0.0722][index], 0); + const values = [luminance(foreground), luminance(background)].sort((a, b) => b - a); + return { + text: element.textContent.trim().slice(0, 80), + foreground, + background, + ratio: (values[0] + 0.05) / (values[1] + 0.05), + outline: style.outline, + shadow: style.boxShadow, + }; + }); +} + +export async function verifyRouteContrast(page, route) { + const targets = []; + const primary = page.getByRole('button', { name: 'New Task', exact: true }); + targets.push(['primary-action', primary]); + if (route === 'drift') + targets.push(['selected-filter', page.getByRole('button', { name: 'all', exact: true })]); + if (route === 'operations') { + const code = page.locator('main code').first(); + await expect(code).toBeVisible(); // Requires the real seeded blocked task, never an empty-state pass. + targets.push(['task-id', code]); + } + const results = []; + for (const [label, target] of targets) { + await expect(target).toBeVisible(); + for (const state of label === 'task-id' ? ['normal'] : ['normal', 'hover', 'focus']) { + if (state === 'hover') await target.hover(); + if (state === 'focus') { + await page.mouse.move(0, 0); + await target.focus(); + } + const measured = await measureTextContrast(target); + assert(measured.ratio >= 4.5, `${route}/${label}/${state}: ${measured.ratio.toFixed(2)}:1`); + results.push({ label, state, ...measured }); + } + } + return results; +} diff --git a/scripts/native-ui/run.mjs b/scripts/native-ui/run.mjs index 57603fb0..56de0394 100644 --- a/scripts/native-ui/run.mjs +++ b/scripts/native-ui/run.mjs @@ -6,6 +6,7 @@ import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { expect } from '@playwright/test'; import { createNativeSession } from './session.mjs'; +import { verifyRouteContrast } from './contrast.mjs'; import { verifyNativeMenuCommands, verifyNativeWindowMenu, @@ -214,6 +215,9 @@ async function capture(entry) { entry.screenshot = { path: name, sha256: await fileDigest(path.join(output, name)) }; assert.deepEqual(geometryFailures(entry.geometry), [], entry.id); const route = routes.find(([name]) => entry.id.endsWith(`/route-${name}`)); + if (route && ['board', 'drift', 'operations'].includes(route[0])) { + entry.contrast = await verifyRouteContrast(page, route[0]); + } if (route) assert.deepEqual( pageHeaderFailures( @@ -662,6 +666,15 @@ try { const windowMenu = await verifyNativeWindowMenu(app, page); page = windowMenu.page; report.menuRoles = windowMenu.roles; + fixtureTask = await createTask('Native public-safe fixture'); + await page.evaluate(async (id) => { + const response = await fetch(`/api/tasks/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'blocked' }), + }); + if (!response.ok) throw new Error(`Fixture status failed: ${response.status}`); + }, fixtureTask.id); await persist(); for (const mode of modes) { for (const state of states) { diff --git a/web/src/__tests__/ui-vocabulary.test.tsx b/web/src/__tests__/ui-vocabulary.test.tsx index 7157247e..866e5144 100644 --- a/web/src/__tests__/ui-vocabulary.test.tsx +++ b/web/src/__tests__/ui-vocabulary.test.tsx @@ -63,6 +63,19 @@ describe('desktop UI vocabulary', () => { expect(click).toHaveBeenCalledTimes(1); }); + it('keeps filled action text above AA contrast in both schemes and hover states', () => { + const css = readFileSync('src/globals.css', 'utf8'); + const fills = [...css.matchAll(/--primary-action(?:-hover)?: (#[a-f0-9]{6});/g)].map( + (match) => match[1] + ); + expect(fills).toHaveLength(4); + for (const fill of fills) { + expect((luminance('#ffffff') + 0.05) / (luminance(fill) + 0.05), fill).toBeGreaterThanOrEqual( + 4.5 + ); + } + }); + it('keeps every semantic foreground above 4.5:1 and the rendered CSS palette in sync', () => { const css = readFileSync('src/globals.css', 'utf8'); for (const [scheme, palette] of Object.entries(VERITAS_SEMANTIC_PALETTE)) { diff --git a/web/src/components/chat/FloatingChat.tsx b/web/src/components/chat/FloatingChat.tsx index 863900ac..8f2fccf9 100644 --- a/web/src/components/chat/FloatingChat.tsx +++ b/web/src/components/chat/FloatingChat.tsx @@ -57,7 +57,7 @@ export function FloatingChat() { classNames={{ icon: 'floating-chat-icon' }} className={cn( 'floating-chat-trigger z-40 h-14 w-14 rounded-full shadow-lg', - 'bg-primary hover:bg-primary/90 text-primary-foreground', + 'bg-primary-action hover:bg-primary-action-hover text-primary-foreground', 'transition-colors duration-150', open && 'hidden' )} diff --git a/web/src/components/digest/OperationsDigestPage.tsx b/web/src/components/digest/OperationsDigestPage.tsx index f73c5b1e..0cefaee6 100644 --- a/web/src/components/digest/OperationsDigestPage.tsx +++ b/web/src/components/digest/OperationsDigestPage.tsx @@ -714,7 +714,10 @@ function SourceList({
{item.label}
- + {item.id} {formatDateTime(item.timestamp)} diff --git a/web/src/components/drift/DriftMonitor.tsx b/web/src/components/drift/DriftMonitor.tsx index 7a4ef569..d00770cf 100644 --- a/web/src/components/drift/DriftMonitor.tsx +++ b/web/src/components/drift/DriftMonitor.tsx @@ -405,9 +405,10 @@ export function DriftMonitor({ onBack }: DriftMonitorProps) { className={cn( 'rounded-md px-3 py-1.5 text-sm capitalize transition-colors', severity === level - ? 'bg-primary text-primary-foreground' + ? 'bg-primary-action text-primary-foreground' : 'text-muted-foreground' )} + aria-pressed={severity === level} onClick={() => setSeverity(level)} > {level} diff --git a/web/src/components/feedback/FeedbackPanel.tsx b/web/src/components/feedback/FeedbackPanel.tsx index e273afee..5f2165c8 100644 --- a/web/src/components/feedback/FeedbackPanel.tsx +++ b/web/src/components/feedback/FeedbackPanel.tsx @@ -215,7 +215,7 @@ function SubmitTab() { className={[ 'rounded-full border px-3 py-1 text-sm transition-colors', selectedCategories.includes(cat) - ? 'border-primary bg-primary text-primary-foreground' + ? 'border-primary bg-primary-action text-primary-foreground' : 'border-border bg-transparent hover:bg-muted', ].join(' ')} > diff --git a/web/src/components/layout/CommandPalette.tsx b/web/src/components/layout/CommandPalette.tsx index dae9bff1..e3b51669 100644 --- a/web/src/components/layout/CommandPalette.tsx +++ b/web/src/components/layout/CommandPalette.tsx @@ -422,7 +422,7 @@ export function CommandPalette({ cmd.disabledReason ? 'cursor-not-allowed border border-dashed border-border/70 bg-muted/15 text-muted-foreground' : isSelected - ? 'bg-primary text-white shadow-sm' + ? 'bg-primary-action text-white shadow-sm' : 'text-foreground hover:bg-muted/50' )} style={ diff --git a/web/src/components/layout/DesktopLeftSidebar.tsx b/web/src/components/layout/DesktopLeftSidebar.tsx index f7b21d59..d8de8b58 100644 --- a/web/src/components/layout/DesktopLeftSidebar.tsx +++ b/web/src/components/layout/DesktopLeftSidebar.tsx @@ -73,7 +73,7 @@ export function DesktopLeftSidebar() { className={cn( 'desktop-no-drag flex min-h-9 items-center gap-2 rounded-md px-2 text-left text-sm transition-colors', active - ? 'bg-primary text-white shadow-sm hover:bg-primary/90' + ? 'bg-primary-action text-white shadow-sm hover:bg-primary-action-hover' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground', !leftRailOpen && 'justify-center px-0' )} diff --git a/web/src/components/shared/ErrorFallback.tsx b/web/src/components/shared/ErrorFallback.tsx index 84e21e5b..5d0fbca5 100644 --- a/web/src/components/shared/ErrorFallback.tsx +++ b/web/src/components/shared/ErrorFallback.tsx @@ -57,7 +57,7 @@ function PageFallback({ error, onRetry: _onRetry }: Omit window.location.reload()} - className="inline-flex items-center gap-2 px-6 py-2.5 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors" + className="inline-flex items-center gap-2 px-6 py-2.5 text-sm font-medium rounded-md bg-primary-action text-primary-foreground hover:bg-primary-action-hover transition-colors" > Reload diff --git a/web/src/components/shared/SkipToContent.tsx b/web/src/components/shared/SkipToContent.tsx index c75f40c5..1eb15033 100644 --- a/web/src/components/shared/SkipToContent.tsx +++ b/web/src/components/shared/SkipToContent.tsx @@ -12,7 +12,7 @@ export function SkipToContent() { sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:rounded-md - focus:bg-primary focus:text-primary-foreground + focus:bg-primary-action focus:text-primary-foreground focus:text-sm focus:font-medium focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:shadow-lg diff --git a/web/src/components/task/TaskCard.tsx b/web/src/components/task/TaskCard.tsx index 668516fd..556485b0 100644 --- a/web/src/components/task/TaskCard.tsx +++ b/web/src/components/task/TaskCard.tsx @@ -400,7 +400,7 @@ export const TaskCard = memo(function TaskCard({ className={cn( 'h-4 w-4 rounded border-2 flex items-center justify-center flex-shrink-0 mt-0.5 transition-colors', isChecked - ? 'bg-primary border-primary text-primary-foreground' + ? 'bg-primary-action border-primary text-primary-foreground' : 'border-muted-foreground/50 hover:border-primary' )} > diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx index 6d5f7cb5..dced9743 100644 --- a/web/src/components/ui/badge.tsx +++ b/web/src/components/ui/badge.tsx @@ -10,7 +10,7 @@ const badgeVariants = cva( { variants: { variant: { - default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + default: 'bg-primary-action text-primary-foreground [a]:hover:bg-primary-action-hover', secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80', destructive: 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20', diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index f1f59b65..833c8b0f 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -15,7 +15,7 @@ const buttonVariants = cva( { variants: { variant: { - default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + default: 'bg-primary-action text-primary-foreground [a]:hover:bg-primary-action-hover', outline: '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', secondary: diff --git a/web/src/globals.css b/web/src/globals.css index e5564d33..77a8e923 100644 --- a/web/src/globals.css +++ b/web/src/globals.css @@ -720,6 +720,8 @@ html[data-client='desktop'] .desktop-board-with-right-rail { --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); --primary: #6541d5; + --primary-action: #6541d5; + --primary-action-hover: #5132b4; --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); @@ -794,8 +796,10 @@ html[data-client='desktop'] .desktop-board-with-right-rail { --card-foreground: oklch(0.985 0 0); --popover: oklch(0.145 0 0); --popover-foreground: oklch(0.985 0 0); - /* Keep Tailwind controls on the same bright Veritas shade as Mantine. */ + /* Preserve bright accent text; filled actions have their own contrast pair. */ --primary: #8d68f8; + --primary-action: #754fe8; + --primary-action-hover: #6541d5; --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); @@ -1334,6 +1338,8 @@ html[data-client='desktop'] .desktop-board-with-right-rail { --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); --color-primary: var(--primary); + --color-primary-action: var(--primary-action); + --color-primary-action-hover: var(--primary-action-hover); --color-popover-foreground: var(--popover-foreground); --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); diff --git a/web/src/theme/mantine-theme.ts b/web/src/theme/mantine-theme.ts index 267a62fb..f54abcbe 100644 --- a/web/src/theme/mantine-theme.ts +++ b/web/src/theme/mantine-theme.ts @@ -1,4 +1,4 @@ -import { createTheme, type MantineColorsTuple } from '@mantine/core'; +import { createTheme, defaultVariantColorsResolver, type MantineColorsTuple } from '@mantine/core'; import { VERITAS_UI_METRICS } from './ui-contract'; export const veritasPrimary: MantineColorsTuple = [ @@ -27,6 +27,19 @@ export const veritasStatusColors = { export const veritasMantineTheme = createTheme({ primaryColor: 'veritas', + // Filled surfaces need a darker swatch than accent text on dark backgrounds. + variantColorResolver: (input) => { + const resolved = defaultVariantColorsResolver(input); + if (input.variant === 'filled' && (input.color ?? input.theme.primaryColor) === 'veritas') { + return { + ...resolved, + background: 'var(--primary-action)', + hover: 'var(--primary-action-hover)', + color: '#ffffff', + }; + } + return resolved; + }, primaryShade: { light: 6, dark: 4 }, colors: { veritas: veritasPrimary,