mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
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:
parent
4ccac06355
commit
2a04b07198
14 changed files with 6496 additions and 1957 deletions
|
|
@ -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
7793
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
14
web/postcss.config.cjs
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
134
web/src/__tests__/mantine-theme.test.tsx
Normal file
134
web/src/__tests__/mantine-theme.test.tsx
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
86
web/src/components/layout/mantine-shell.tsx
Normal file
86
web/src/components/layout/mantine-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
76
web/src/theme/MantineRoot.tsx
Normal file
76
web/src/theme/MantineRoot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
web/src/theme/color-scheme.ts
Normal file
17
web/src/theme/color-scheme.ts
Normal 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');
|
||||
}
|
||||
136
web/src/theme/mantine-theme.ts
Normal file
136
web/src/theme/mantine-theme.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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/') ||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue