mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-09 05:07:53 +00:00
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:
commit
bef848b26f
17 changed files with 144 additions and 13 deletions
|
|
@ -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.
|
||||
|
|
|
|||
72
scripts/native-ui/contrast.mjs
Normal file
72
scripts/native-ui/contrast.mjs
Normal 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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(' ')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue