Merge pull request #1561 from BradGroux/fix/dark-mode-contrast-1532

fix: improve primary-action and code contrast in dark mode
This commit is contained in:
Brad Groux 2026-09-07 19:00:07 -05:00 • committed by GitHub
commit bef848b26f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 144 additions and 13 deletions

View file

@ -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.

View file

@ -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;
}

View file

@ -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) {

View file

@ -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)) {

View file

@ -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'
)}

View file

@ -714,7 +714,10 @@ function SourceList({
<div key={`${item.kind}:${item.id}`} className="min-w-0 text-sm">
<div className="truncate">{item.label}</div>
<div className="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<Code className="truncate" color="dark">
<Code
className="truncate"
style={{ color: 'var(--foreground)', backgroundColor: 'var(--muted)' }}
>
{item.id}
</Code>
<span className="shrink-0">{formatDateTime(item.timestamp)}</span>

View file

@ -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}

View file

@ -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(' ')}
>

View file

@ -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={

View file

@ -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'
)}

View file

@ -57,7 +57,7 @@ function PageFallback({ error, onRetry: _onRetry }: Omit<ErrorFallbackProps, 'le
<button
onClick={() => 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"
>
<RefreshCw className="h-4 w-4" />
Reload

View file

@ -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

View file

@ -415,7 +415,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'
)}
>

View file

@ -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',

View file

@ -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:

View file

@ -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);

View file

@ -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,