feat: add v5 Mantine foundation

## Summary

- adds Mantine core/hooks/form/modals/notifications plus the required PostCSS setup
- wraps the web app and shared test renderer in a Veritas Mantine provider with modals, notifications, and color-scheme bridging
- defines the v5 Mantine theme, status colors, density defaults, breakpoints, and layout shell primitives
- keeps the existing `.dark` class contract active while migrated and unmigrated surfaces coexist
- documents the foundation conventions and bundle impact for the v5 UI migration

Closes #415.

## Verification

- CI: Build
- CI: Lint & Type Check
- CI: Security Audit
- CI: Workspace Unit Tests
- `pnpm --filter @veritas-kanban/web test -- mantine-theme`
- `pnpm --filter @veritas-kanban/web typecheck`
- `pnpm --filter @veritas-kanban/web test`
- `pnpm lint:budget`
- `pnpm audit --prod --audit-level=high`
- `pnpm build`
- `./node_modules/.bin/prettier --check docs/UI-MANTINE-MIGRATION.md web/index.html web/postcss.config.cjs web/src/__tests__/mantine-theme.test.tsx web/src/__tests__/test-utils.tsx web/src/components/layout/mantine-shell.tsx web/src/hooks/useTheme.ts web/src/main.tsx web/src/theme/color-scheme.ts web/src/theme/mantine-theme.ts web/src/theme/MantineRoot.tsx web/vite.config.ts web/package.json`
- `git diff --check`
- Browser smoke: `http://127.0.0.1:3000/` rendered the setup page with Mantine CSS variables, dark color scheme, no new console errors, and no horizontal overflow at 1280x720 or 390x844

## Notes

- Production audit still reports the existing 3 moderate advisories; the high-severity gate passes.
- Build now emits explicit Mantine vendor assets: `vendor-mantine-CvPmQ6ZW.css` at 214.56 kB / 31.59 kB gzip and `vendor-mantine-JZsLAwaX.js` at 148.11 kB / 45.81 kB gzip.
This commit is contained in:
Brad Groux 2026-05-31 03:05:48 -05:00 committed by GitHub
parent 4ccac06355
commit 2a04b07198
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 6496 additions and 1957 deletions

View file

@ -82,6 +82,18 @@ Mantine becomes the primary component system for v5.0:
| Theme | `MantineProvider`, CSS variables, color scheme manager | Keep Veritas tokens as the source of product color decisions |
| Layout primitives | `Stack`, `Group`, `Grid`, `SimpleGrid`, `Flex`, `Box` | Reduce repeated Tailwind layout strings in migrated surfaces |
Foundation files:
- `web/src/theme/MantineRoot.tsx` owns `MantineProvider`, `ModalsProvider`,
notifications, and the Veritas color-scheme bridge.
- `web/src/theme/mantine-theme.ts` owns the Veritas Mantine theme, status
colors, density settings, breakpoints, focus behavior, radius, shadows, and
component defaults.
- `web/src/theme/color-scheme.ts` keeps Mantine color scheme state and the
existing `.dark` class in sync while shadcn/Tailwind surfaces still exist.
- `web/src/components/layout/mantine-shell.tsx` contains the first Mantine app
shell primitives for future desktop/mobile surfaces.
Retained custom surfaces:
- Kanban board column/card drag and drop built on `@dnd-kit`
@ -134,6 +146,37 @@ Breakpoints:
- Treat desktop app, browser, and mobile/PWA as supported layout modes.
- Avoid viewport-scaled font sizes; use responsive layout changes instead.
## Mantine Usage Conventions
- New v5 surfaces should import Mantine components directly or use Veritas
Mantine wrappers from `web/src/components/layout/mantine-shell.tsx` when the
wrapper encodes a product layout convention.
- Do not add new shadcn/Radix wrapper usage for new v5 surfaces unless a
migration PR explicitly needs a compatibility bridge.
- Use Mantine `Stack`, `Group`, `Grid`, `SimpleGrid`, `Flex`, and `Box` for
new layout code before adding long Tailwind layout strings.
- Use Mantine `ActionIcon` for icon-only commands and keep lucide icons inside
the control.
- Use Mantine form controls and built-in error/description props for new forms.
- Use Mantine `Modal`/`Drawer`/`Popover` for new overlays. Keep existing
shadcn overlays only until their containing surface is migrated.
- Keep Veritas-specific board, diff, workflow timeline, chart, markdown, and
work product components custom.
- Read status colors from `theme.other.statusColors` or from
`veritasStatusColors`; do not scatter new one-off status hex values.
- Keep the app dark-mode first. Validate light mode, but do not let light-mode
defaults drive density or spacing decisions.
- When adding a new Mantine dependency beyond core/hooks/forms/notifications/
modals, document why the package is needed in the PR.
Foundation verification currently covers:
- default dark color-scheme bridge to the existing `.dark` class
- Mantine provider availability in the web app and test utility wrapper
- required v5 status semantics: blocked, needs review, running, done, failed,
warning, policy denied, and destructive
- reduced-motion, focus ring, auto contrast, and breakpoint defaults
## Migration Order
### Phase 0: Inventory and Guardrails

7793
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,20 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<script>
// Apply theme immediately to prevent flash — default dark
(function() {
var t = localStorage.getItem('veritas-kanban-theme');
if (t !== 'light') document.documentElement.classList.add('dark');
// Apply theme immediately to prevent flash. Default dark.
(function () {
var value = null;
try {
value = localStorage.getItem('veritas-kanban-theme');
} catch (error) {
value = null;
}
var theme = value === 'light' ? 'light' : 'dark';
document.documentElement.dataset.mantineColorScheme = theme;
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
})();
</script>
<meta charset="UTF-8" />

View file

@ -17,6 +17,11 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@mantine/core": "^9.2.2",
"@mantine/form": "^9.2.2",
"@mantine/hooks": "^9.2.2",
"@mantine/modals": "^9.2.2",
"@mantine/notifications": "^9.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@ -65,6 +70,8 @@
"eslint": "^9.17.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.14",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vite": "^8.0.12",

14
web/postcss.config.cjs Normal file
View file

@ -0,0 +1,14 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};

View file

@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { TextInput, useMantineTheme } from '@mantine/core';
import { useTheme } from '@/hooks/useTheme';
import { MantineRoot, testColorSchemeManager } from '@/theme/MantineRoot';
import { veritasMantineTheme, veritasStatusColors } from '@/theme/mantine-theme';
function ThemeProbe() {
const theme = useMantineTheme();
return (
<div>
<span data-testid="primary">{theme.primaryColor}</span>
<span data-testid="blocked">{theme.other.statusColors.blocked}</span>
<TextInput label="Probe" placeholder="Mantine input" />
</div>
);
}
function ThemeControlProbe() {
const { theme, setTheme } = useTheme();
return (
<button type="button" onClick={() => setTheme('light')}>
{theme}
</button>
);
}
describe('Mantine foundation', () => {
beforeEach(() => {
const storage = new Map<string, string>();
const localStorageMock: Storage = {
get length() {
return storage.size;
},
clear: vi.fn(() => storage.clear()),
getItem: vi.fn((key: string) => storage.get(key) ?? null),
key: vi.fn((index: number) => Array.from(storage.keys())[index] ?? null),
removeItem: vi.fn((key: string) => {
storage.delete(key);
}),
setItem: vi.fn((key: string, value: string) => {
storage.set(key, value);
}),
};
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: localStorageMock,
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
afterEach(() => {
cleanup();
testColorSchemeManager.clear();
window.localStorage.clear();
document.documentElement.classList.remove('dark');
delete document.documentElement.dataset.mantineColorScheme;
vi.restoreAllMocks();
});
it('provides the Veritas Mantine theme and default dark color scheme', async () => {
render(
<MantineRoot env="test">
<ThemeProbe />
</MantineRoot>
);
expect(screen.getByTestId('primary').textContent).toBe('veritas');
expect(screen.getByTestId('blocked').textContent).toBe(veritasStatusColors.blocked);
expect(screen.getByLabelText('Probe')).toBeDefined();
await waitFor(() => {
expect(document.documentElement.dataset.mantineColorScheme).toBe('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
it('bridges Mantine color scheme changes to the existing dark class contract', async () => {
render(
<MantineRoot env="test">
<ThemeControlProbe />
</MantineRoot>
);
await waitFor(() => expect(screen.getByRole('button').textContent).toBe('dark'));
fireEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('button').textContent).toBe('light');
expect(document.documentElement.dataset.mantineColorScheme).toBe('light');
expect(document.documentElement.classList.contains('dark')).toBe(false);
});
});
it('covers required v5 status semantics and accessibility defaults', () => {
expect(Object.keys(veritasStatusColors).sort()).toEqual([
'blocked',
'destructive',
'done',
'failed',
'needsReview',
'policyDenied',
'running',
'warning',
]);
expect(veritasMantineTheme.focusRing).toBe('always');
expect(veritasMantineTheme.respectReducedMotion).toBe(true);
expect(veritasMantineTheme.autoContrast).toBe(true);
expect(veritasMantineTheme.breakpoints).toEqual({
xs: '36em',
sm: '48em',
md: '62em',
lg: '75em',
xl: '88em',
});
});
});

View file

@ -5,6 +5,7 @@ import React, { type ReactNode } from 'react';
import { render, type RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WebSocketStatusProvider } from '@/contexts/WebSocketContext';
import { MantineRoot } from '@/theme/MantineRoot';
import type { ConnectionState } from '@/hooks/useWebSocket';
import type {
Task,
@ -46,6 +47,54 @@ const DEFAULT_WS_STATUS: TestWebSocketStatus = {
reconnectAttempt: 0,
};
function createMemoryStorage(): Storage {
const storage = new Map<string, string>();
return {
get length() {
return storage.size;
},
clear: () => storage.clear(),
getItem: (key: string) => storage.get(key) ?? null,
key: (index: number) => Array.from(storage.keys())[index] ?? null,
removeItem: (key: string) => {
storage.delete(key);
},
setItem: (key: string, value: string) => {
storage.set(key, value);
},
};
}
function ensureMantineBrowserApis() {
if (typeof window === 'undefined') return;
if (typeof window.matchMedia !== 'function') {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}),
});
}
try {
window.localStorage.getItem('__veritas_test_probe__');
} catch {
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: createMemoryStorage(),
});
}
}
// ── All Providers Wrapper ────────────────────────────────────
interface AllProvidersProps {
@ -55,18 +104,21 @@ interface AllProvidersProps {
}
function AllProviders({ children, queryClient, wsStatus }: AllProvidersProps) {
ensureMantineBrowserApis();
const qc = queryClient ?? createTestQueryClient();
const ws = { ...DEFAULT_WS_STATUS, ...wsStatus };
return (
<QueryClientProvider client={qc}>
<WebSocketStatusProvider
isConnected={ws.isConnected}
connectionState={ws.connectionState}
reconnectAttempt={ws.reconnectAttempt}
>
{children}
</WebSocketStatusProvider>
</QueryClientProvider>
<MantineRoot env="test">
<QueryClientProvider client={qc}>
<WebSocketStatusProvider
isConnected={ws.isConnected}
connectionState={ws.connectionState}
reconnectAttempt={ws.reconnectAttempt}
>
{children}
</WebSocketStatusProvider>
</QueryClientProvider>
</MantineRoot>
);
}

View file

@ -0,0 +1,86 @@
import type { ReactNode } from 'react';
import {
AppShell,
Box,
Group,
ScrollArea,
Stack,
type AppShellNavbarConfiguration,
type BoxProps,
type GroupProps,
type StackProps,
} from '@mantine/core';
interface VeritasAppShellProps {
header: ReactNode;
children: ReactNode;
sidebar?: ReactNode;
navbar?: Partial<AppShellNavbarConfiguration>;
}
export function VeritasAppShell({ header, children, sidebar, navbar }: VeritasAppShellProps) {
return (
<AppShell
header={{ height: 56 }}
navbar={
sidebar
? {
width: 280,
breakpoint: 'md',
collapsed: { mobile: true },
...navbar,
}
: undefined
}
padding="md"
>
<AppShell.Header>{header}</AppShell.Header>
{sidebar ? (
<AppShell.Navbar>
<ScrollArea h="100%">{sidebar}</ScrollArea>
</AppShell.Navbar>
) : null}
<AppShell.Main>{children}</AppShell.Main>
</AppShell>
);
}
export function VeritasHeaderActions({ children, ...props }: GroupProps) {
return (
<Group gap="xs" justify="flex-end" wrap="nowrap" {...props}>
{children}
</Group>
);
}
export function VeritasSideNav({ children, ...props }: StackProps) {
return (
<Stack gap={4} p="sm" {...props}>
{children}
</Stack>
);
}
export function VeritasPanel({ children, ...props }: BoxProps & { children: ReactNode }) {
return (
<Box
bg="var(--mantine-color-body)"
p="md"
style={{
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 'var(--mantine-radius-md)',
}}
{...props}
>
{children}
</Box>
);
}
export function VeritasDialogStack({ children, ...props }: StackProps) {
return (
<Stack gap="md" {...props}>
{children}
</Stack>
);
}

View file

@ -1,37 +1,35 @@
import { useState, useEffect, useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { useComputedColorScheme, useMantineColorScheme } from '@mantine/core';
import {
applyVeritasColorScheme,
normalizeVeritasColorScheme,
type VeritasColorScheme,
} from '@/theme/color-scheme';
type Theme = 'light' | 'dark';
const STORAGE_KEY = 'veritas-kanban-theme';
function getInitialTheme(): Theme {
if (typeof window === 'undefined') return 'dark';
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
return 'dark'; // default
}
type Theme = VeritasColorScheme;
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme('dark', {
getInitialValueInEffect: true,
});
const theme = normalizeVeritasColorScheme(computedColorScheme);
// Apply theme class to <html>
useEffect(() => {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem(STORAGE_KEY, theme);
applyVeritasColorScheme(theme);
}, [theme]);
const setTheme = useCallback((t: Theme) => {
setThemeState(t);
}, []);
const setTheme = useCallback(
(t: Theme) => {
applyVeritasColorScheme(t);
setColorScheme(t);
},
[setColorScheme]
);
const toggleTheme = useCallback(() => {
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
}, []);
setTheme(theme === 'dark' ? 'light' : 'dark');
}, [setTheme, theme]);
return { theme, setTheme, toggleTheme };
}

View file

@ -2,6 +2,9 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import { MantineRoot } from './theme/MantineRoot';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import './globals.css';
const queryClient = new QueryClient({
@ -16,7 +19,9 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<MantineRoot>
<App />
</MantineRoot>
</QueryClientProvider>
</React.StrictMode>
);

View file

@ -0,0 +1,76 @@
import { useEffect, type ReactNode } from 'react';
import {
MantineProvider,
type MantineColorScheme,
type MantineColorSchemeManager,
localStorageColorSchemeManager,
useComputedColorScheme,
} from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
import { Notifications } from '@mantine/notifications';
import { veritasMantineTheme } from './mantine-theme';
import {
applyVeritasColorScheme,
normalizeVeritasColorScheme,
VERITAS_COLOR_SCHEME_STORAGE_KEY,
} from './color-scheme';
export const veritasColorSchemeManager = localStorageColorSchemeManager({
key: VERITAS_COLOR_SCHEME_STORAGE_KEY,
});
let testColorScheme: MantineColorScheme = 'dark';
export const testColorSchemeManager: MantineColorSchemeManager = {
get: (defaultValue) => testColorScheme ?? defaultValue,
set: (value) => {
testColorScheme = value;
},
subscribe: () => {},
unsubscribe: () => {},
clear: () => {
testColorScheme = 'dark';
},
};
interface MantineRootProps {
children: ReactNode;
env?: 'default' | 'test';
}
function VeritasColorSchemeSync() {
const computedColorScheme = useComputedColorScheme('dark', {
getInitialValueInEffect: true,
});
useEffect(() => {
applyVeritasColorScheme(normalizeVeritasColorScheme(computedColorScheme));
}, [computedColorScheme]);
return null;
}
function VeritasTestColorSchemeSync() {
useEffect(() => {
applyVeritasColorScheme('dark');
}, []);
return null;
}
export function MantineRoot({ children, env = 'default' }: MantineRootProps) {
return (
<MantineProvider
theme={veritasMantineTheme}
colorSchemeManager={env === 'test' ? testColorSchemeManager : veritasColorSchemeManager}
defaultColorScheme="dark"
env={env}
>
{env === 'test' ? <VeritasTestColorSchemeSync /> : <VeritasColorSchemeSync />}
<ModalsProvider>
{children}
<Notifications position="bottom-right" limit={5} zIndex={5000} />
</ModalsProvider>
</MantineProvider>
);
}

View file

@ -0,0 +1,17 @@
import type { MantineColorScheme } from '@mantine/core';
export type VeritasColorScheme = 'light' | 'dark';
export const VERITAS_COLOR_SCHEME_STORAGE_KEY = 'veritas-kanban-theme';
export function normalizeVeritasColorScheme(value: MantineColorScheme): VeritasColorScheme {
return value === 'light' ? 'light' : 'dark';
}
export function applyVeritasColorScheme(value: VeritasColorScheme): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.dataset.mantineColorScheme = value;
root.classList.toggle('dark', value === 'dark');
}

View file

@ -0,0 +1,136 @@
import { createTheme, type MantineColorsTuple } from '@mantine/core';
export const veritasPrimary: MantineColorsTuple = [
'#f4f1ff',
'#e6dcff',
'#cab8ff',
'#aa8eff',
'#8d68f8',
'#754fe8',
'#6541d5',
'#5132b4',
'#412996',
'#37267a',
];
export const veritasStatusColors = {
blocked: '#f59f00',
needsReview: '#228be6',
running: '#7950f2',
done: '#2f9e44',
failed: '#fa5252',
warning: '#f08c00',
policyDenied: '#e03131',
destructive: '#e03131',
} as const;
export const veritasMantineTheme = createTheme({
primaryColor: 'veritas',
primaryShade: { light: 6, dark: 4 },
colors: {
veritas: veritasPrimary,
},
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
fontFamilyMonospace:
"'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, ui-monospace, monospace",
headings: {
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
fontWeight: '650',
},
defaultRadius: 'sm',
radius: {
xs: '3px',
sm: '5px',
md: '7px',
lg: '10px',
xl: '14px',
},
spacing: {
xs: '0.375rem',
sm: '0.5rem',
md: '0.75rem',
lg: '1rem',
xl: '1.5rem',
},
fontSizes: {
xs: '0.75rem',
sm: '0.8125rem',
md: '0.875rem',
lg: '1rem',
xl: '1.125rem',
},
lineHeights: {
xs: '1.25',
sm: '1.35',
md: '1.45',
lg: '1.5',
xl: '1.55',
},
shadows: {
xs: '0 1px 2px rgba(0, 0, 0, 0.2)',
sm: '0 4px 12px rgba(0, 0, 0, 0.18)',
md: '0 10px 30px rgba(0, 0, 0, 0.22)',
lg: '0 20px 50px rgba(0, 0, 0, 0.28)',
xl: '0 28px 70px rgba(0, 0, 0, 0.32)',
},
breakpoints: {
xs: '36em',
sm: '48em',
md: '62em',
lg: '75em',
xl: '88em',
},
focusRing: 'always',
respectReducedMotion: true,
cursorType: 'pointer',
autoContrast: true,
other: {
density: {
controlHeight: 34,
compactControlHeight: 30,
panelRadius: 7,
cardRadius: 7,
},
statusColors: veritasStatusColors,
},
components: {
Button: {
defaultProps: {
radius: 'sm',
},
},
ActionIcon: {
defaultProps: {
radius: 'sm',
variant: 'subtle',
},
},
Modal: {
defaultProps: {
radius: 'md',
centered: true,
overlayProps: { blur: 2, opacity: 0.45 },
},
},
Drawer: {
defaultProps: {
overlayProps: { blur: 2, opacity: 0.35 },
},
},
TextInput: {
defaultProps: {
radius: 'sm',
},
},
Textarea: {
defaultProps: {
radius: 'sm',
},
},
Select: {
defaultProps: {
radius: 'sm',
},
},
},
});

View file

@ -39,6 +39,9 @@ export default defineConfig({
if (id.includes('node_modules/react/') || id.includes('node_modules/react-dom/')) {
return 'vendor-react';
}
if (id.includes('node_modules/@mantine/')) {
return 'vendor-mantine';
}
if (
id.includes('node_modules/@radix-ui/react-dialog/') ||
id.includes('node_modules/@radix-ui/react-popover/') ||