diff --git a/README.md b/README.md
index 40f7e7ef..bac1600c 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,7 @@ When the board is working, use [Setup Paths](docs/SETUP-PATHS.md) to choose the
- [API Reference](docs/API-REFERENCE.md) — Auth, endpoints, request/response examples, WebSocket, common workflows.
- [v5 Identity and RBAC Model](docs/IDENTITY-RBAC.md) — users, workspaces, memberships, roles, agent tokens, permission matrix, migration, and UX flows.
- [v5 Mantine Migration Plan](docs/UI-MANTINE-MIGRATION.md) — component inventory, migration order, retained custom surfaces, rollback strategy, and cleanup gates.
+- [v5 GA Checklist](docs/V5-GA-CHECKLIST.md) — final release gates, Mantine visual/accessibility cleanup evidence, bundle checks, and holdout tracking.
- [v5 Desktop Architecture ADR](docs/architecture/ADR-0001-v5-desktop-architecture.md) — shell decision, native/server boundaries, connection modes, lifecycle, packaging, and security model.
- [Self-Hosting Guide](docs/guides/SELF_HOST.md) — production deployment, reverse proxy, auth hardening, Docker, and backups.
- [Agent Task Workflow SOP](docs/SOP-agent-task-workflow.md) — lifecycle, API/CLI snippets, prompts.
diff --git a/docs/UI-MANTINE-MIGRATION.md b/docs/UI-MANTINE-MIGRATION.md
index 439a50c6..bdd96d6a 100644
--- a/docs/UI-MANTINE-MIGRATION.md
+++ b/docs/UI-MANTINE-MIGRATION.md
@@ -418,14 +418,20 @@ Phase 3 progress:
Target issue: `v5.0 QA: Mantine migration visual, accessibility, and cleanup gate`
-- Run visual smoke checks against desktop and mobile viewports.
+- Run `pnpm test:e2e -- e2e/mantine-qa-gate.spec.ts` to capture desktop
+ dark/light and mobile visual smoke evidence for the current route inventory
+ plus migrated overlays.
- Run keyboard/focus checks for dialog, drawer, tabs, select, command/search,
- task create/edit, and settings flows.
-- Measure bundle size after each large surface migration.
-- Remove unused Radix/shadcn dependencies only after import counts prove they
- are unused.
-- Keep a cleanup ledger of removed wrappers and accepted retained custom
- components.
+ task create/edit, settings, and auth/setup flows through the Mantine QA gate.
+- Run `pnpm --filter @veritas-kanban/web build` and then `pnpm qa:mantine` to
+ verify bundle output, active wrapper-import cleanup, dependency cleanup, and
+ v5 GA checklist coverage.
+- Remove unused Radix/shadcn dependencies only after import counts and bundle
+ output prove they are unused.
+- Keep a cleanup ledger of removed wrappers, accepted retained custom
+ components, and planned-but-not-yet-present surfaces. Current temporary
+ holdouts are unified work products, maintenance center, workflow visual
+ builder, and final run replay view.
## Component Mapping
@@ -482,6 +488,7 @@ Minimum gates for foundation and shared primitive PRs:
- `pnpm --filter @veritas-kanban/web test`
- `pnpm lint:budget`
- `pnpm build`
+- `pnpm qa:mantine` after `pnpm --filter @veritas-kanban/web build`
- visual smoke screenshots for app boot, board, task detail, create task,
settings, command/search, and one mobile viewport once browser automation is
added to the migration branch
@@ -493,6 +500,14 @@ Minimum gates for route-level migration PRs:
- desktop and mobile screenshot comparison
- bundle-size note when a lazy chunk changes materially
+Minimum gate for closing #418:
+
+- `pnpm test:e2e -- e2e/mantine-qa-gate.spec.ts`
+- `pnpm --filter @veritas-kanban/web build`
+- `pnpm qa:mantine`
+- issue and PR notes list any temporary holdouts that map to not-yet-landed v5
+ feature issues
+
## Completion Criteria
Issue #414 is complete when:
diff --git a/docs/V5-GA-CHECKLIST.md b/docs/V5-GA-CHECKLIST.md
new file mode 100644
index 00000000..0e7a3c00
--- /dev/null
+++ b/docs/V5-GA-CHECKLIST.md
@@ -0,0 +1,56 @@
+# Veritas Kanban v5 GA Checklist
+
+This checklist tracks the release evidence that must be true before v5.0 GA.
+The GitHub epic remains the source of scheduling truth; this document is the
+operator checklist for final release verification.
+
+## Required Release Gates
+
+- [ ] Fresh install verifies the desktop app can start the bundled server, load
+ the renderer, and create or open a board.
+- [ ] Upgrade verifies a v4 file-backed project can migrate to SQLite and can
+ recover through the rollback drill.
+- [ ] Backup and restore verifies SQLite data, task files, settings, templates,
+ attachments, workflow state, and audit history.
+- [ ] Multi-user mode verifies workspace switching, memberships, invitations,
+ scoped API tokens, actor attribution, and RBAC denial paths.
+- [ ] Remote mode verifies pairing, trusted host validation, token/session
+ lifecycle, WebSocket sync, and local-only secret handling.
+- [ ] Security review covers desktop bridge calls, auth/session handling,
+ scoped tokens, remote access, workflow execution, and agent tool gates.
+- [ ] Performance/load review covers SQLite read/write paths, dashboard queries,
+ WebSocket fan-out, workflow run updates, and remote/mobile clients.
+- [ ] Docs cover upgrade, desktop install, remote access, admin operations,
+ backup/restore, diagnostics, and known platform limits.
+
+## Mantine component-system cleanup gate
+
+Run this gate before closing #418, #417, or the v5 release checklist issue.
+
+- [ ] Run `pnpm --filter @veritas-kanban/web build` before the bundle check.
+- [ ] Run `pnpm qa:mantine` and keep the output in the PR verification notes.
+- [ ] Run `pnpm test:e2e -- e2e/mantine-qa-gate.spec.ts` and keep the generated
+ visual and accessibility evidence attached to the Playwright run.
+- [ ] Confirm visual smoke screenshots cover desktop dark mode, desktop light
+ mode, and mobile dark mode for every current v5 GA route.
+- [ ] Confirm keyboard navigation, focus traps, screen-reader names, reduced
+ horizontal overflow, and mobile touch-target checks pass for board, task
+ detail, create task, settings, command/search, and auth/setup flows.
+- [ ] Confirm current route coverage includes board, activity, backlog, archive,
+ templates, workflows, drift, decisions, scoring, policies, dashboard
+ surfaces on the board, and the migrated overlays.
+- [ ] Track planned-but-not-yet-present surfaces as temporary holdouts instead
+ of marking them covered. Current holdouts: unified work products,
+ maintenance center, workflow visual builder, and final run replay view.
+- [ ] Confirm no v5 GA-blocking route imports the old primitive compatibility
+ wrappers except explicitly retained custom surfaces and wrapper internals.
+- [ ] Confirm no `shadcn` package, direct `@radix-ui/react-*` package, or
+ `vendor-radix` bundle chunk is present.
+- [ ] Confirm bundle budgets remain within the `pnpm qa:mantine` thresholds or
+ record an explicit release-risk acceptance.
+
+## Final Sign-Off Notes
+
+Each GA release candidate should link the PRs or workflow runs that satisfy the
+gates above. If a gate is intentionally deferred, link the follow-up issue and
+state the user-visible risk in release notes.
diff --git a/docs/index.html b/docs/index.html
index 8ecbfaa9..792215fb 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -1604,7 +1604,7 @@ curl -X POST .../tasks/<id>/comments \
⚛️
Frontend
-
React 19 + TypeScript Vite 7 + Tailwind CSS shadcn/ui components
+
React 19 + TypeScript Vite + Tailwind CSS Mantine UI foundation
🟢
diff --git a/e2e/mantine-qa-gate.spec.ts b/e2e/mantine-qa-gate.spec.ts
new file mode 100644
index 00000000..b2c37b51
--- /dev/null
+++ b/e2e/mantine-qa-gate.spec.ts
@@ -0,0 +1,623 @@
+import { expect, type Page, type TestInfo, test } from '@playwright/test';
+import { bypassAuth, cleanupRoutes, deleteTask, seedTestTask } from './helpers/auth';
+
+const COLOR_SCHEME_STORAGE_KEY = 'veritas-kanban-theme';
+
+const desktopViewport = { width: 1440, height: 1000 };
+const mobileViewport = { width: 390, height: 844 };
+
+const routeSurfaces = [
+ {
+ name: 'board',
+ path: '/',
+ ready: async (page: Page) => {
+ await expect(page.getByRole('region', { name: 'To Do' })).toBeVisible({
+ timeout: 15_000,
+ });
+ },
+ },
+ {
+ name: 'activity',
+ path: '/activity',
+ ready: async (page: Page) => {
+ await expect(page.getByRole('heading', { name: 'Activity' })).toBeVisible({
+ timeout: 15_000,
+ });
+ },
+ },
+ {
+ name: 'backlog',
+ path: '/backlog',
+ ready: async (page: Page) => {
+ await expect(page.getByRole('heading', { name: 'Backlog' })).toBeVisible({
+ timeout: 15_000,
+ });
+ },
+ },
+ {
+ name: 'archive',
+ path: '/archive',
+ ready: async (page: Page) => {
+ await expect(page.getByRole('heading', { name: 'Archive' })).toBeVisible({
+ timeout: 15_000,
+ });
+ },
+ },
+ {
+ name: 'templates',
+ path: '/templates',
+ ready: async (page: Page) => {
+ await expect(page.getByRole('heading', { name: 'Task Templates' })).toBeVisible({
+ timeout: 15_000,
+ });
+ },
+ },
+ {
+ name: 'workflows',
+ path: '/workflows',
+ ready: async (page: Page) => {
+ await expect(page.getByRole('heading', { name: 'Workflows' })).toBeVisible({
+ timeout: 15_000,
+ });
+ },
+ },
+ {
+ name: 'drift',
+ path: '/drift',
+ ready: async (page: Page) => {
+ await expect(page.getByRole('heading', { name: 'Behavioral Drift Monitor' })).toBeVisible({
+ timeout: 15_000,
+ });
+ },
+ },
+ {
+ name: 'decisions',
+ path: '/decisions',
+ ready: async (page: Page) => {
+ await expect(page.getByRole('heading', { name: 'Decision Audit Trail' })).toBeVisible({
+ timeout: 15_000,
+ });
+ },
+ },
+ {
+ name: 'scoring',
+ path: '/scoring',
+ ready: async (page: Page) => {
+ await expect(page.getByRole('heading', { name: 'Agent Output Scoring' })).toBeVisible({
+ timeout: 15_000,
+ });
+ },
+ },
+ {
+ name: 'policies',
+ path: '/policies',
+ ready: async (page: Page) => {
+ await expect(page.getByRole('heading', { name: 'Agent Policies' })).toBeVisible({
+ timeout: 15_000,
+ });
+ },
+ },
+] as const;
+
+async function mockAgentStatus(page: Page) {
+ await page.route(/\/api\/agent\/status(?:\?.*)?$/, (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ success: true,
+ data: {
+ status: 'idle',
+ subAgentCount: 0,
+ activeAgents: [],
+ lastUpdated: new Date().toISOString(),
+ },
+ meta: {
+ timestamp: new Date().toISOString(),
+ },
+ }),
+ })
+ );
+}
+
+async function mockEmptyTaskChatSessions(page: Page) {
+ await page.route('**/api/chat/sessions/task_*', (route) => {
+ const sessionId = new URL(route.request().url()).pathname.split('/').pop() ?? 'task_unknown';
+ const taskId = sessionId.replace(/^task_/, '');
+ const timestamp = new Date().toISOString();
+
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ id: sessionId,
+ taskId,
+ title: 'Task Chat',
+ messages: [],
+ agent: 'default',
+ mode: 'ask',
+ created: timestamp,
+ updated: timestamp,
+ }),
+ });
+ });
+}
+
+function getSeededTaskId(task: Record
) {
+ const directId = task.id;
+ const dataId =
+ task.data && typeof task.data === 'object' ? (task.data as Record).id : null;
+ const id = typeof directId === 'string' ? directId : dataId;
+
+ return typeof id === 'string' ? id : null;
+}
+
+function captureUnexpectedBrowserErrors(page: Page): string[] {
+ const messages: string[] = [];
+
+ page.on('console', (message) => {
+ if (message.type() === 'error') {
+ const location = message.location();
+ messages.push(`${message.text()}${location.url ? ` (${location.url})` : ''}`);
+ }
+ });
+
+ page.on('pageerror', (error) => {
+ messages.push(error.message);
+ });
+
+ return messages;
+}
+
+async function setColorScheme(page: Page, scheme: 'dark' | 'light') {
+ await page.evaluate(
+ ({ key, value }) => {
+ window.localStorage.setItem(key, value);
+ document.documentElement.dataset.mantineColorScheme = value;
+ document.documentElement.classList.toggle('dark', value === 'dark');
+ },
+ { key: COLOR_SCHEME_STORAGE_KEY, value: scheme }
+ );
+}
+
+async function attachViewportScreenshot(
+ page: Page,
+ testInfo: TestInfo,
+ name: string,
+ scheme: 'dark' | 'light'
+) {
+ const screenshot = await page.screenshot({
+ animations: 'disabled',
+ fullPage: false,
+ });
+
+ await testInfo.attach(`${name}-${scheme}-${page.viewportSize()?.width ?? 'viewport'}.png`, {
+ body: screenshot,
+ contentType: 'image/png',
+ });
+}
+
+async function assertColorScheme(page: Page, scheme: 'dark' | 'light') {
+ await expect(page.locator('html')).toHaveAttribute('data-mantine-color-scheme', scheme);
+
+ const hasDarkClass = await page
+ .locator('html')
+ .evaluate((element) => element.classList.contains('dark'));
+ expect(hasDarkClass).toBe(scheme === 'dark');
+}
+
+async function assertNoLegacyPrimitiveSlots(page: Page) {
+ const legacySlots = page.locator(
+ [
+ '[data-slot="alert-dialog-content"]',
+ '[data-slot="button"]',
+ '[data-slot="checkbox"]',
+ '[data-slot="dialog-content"]',
+ '[data-slot="input"]',
+ '[data-slot="label"]',
+ '[data-slot="select-trigger"]',
+ '[data-slot="sheet-content"]',
+ '[data-slot="switch"]',
+ '[data-slot="tabs-list"]',
+ '[data-slot="textarea"]',
+ '[data-slot="tooltip-content"]',
+ ].join(',')
+ );
+
+ await expect(legacySlots).toHaveCount(0);
+}
+
+async function assertNoHorizontalOverflow(page: Page) {
+ const overflow = await page.evaluate(() => ({
+ clientWidth: document.documentElement.clientWidth,
+ offenders: Array.from(document.querySelectorAll('body *'))
+ .map((element) => {
+ const rect = element.getBoundingClientRect();
+ return {
+ html: element.outerHTML.slice(0, 220),
+ right: Math.round(rect.right),
+ width: Math.round(rect.width),
+ };
+ })
+ .filter((entry) => entry.right > document.documentElement.clientWidth + 2)
+ .sort((a, b) => b.right - a.right)
+ .slice(0, 5),
+ scrollWidth: document.documentElement.scrollWidth,
+ }));
+
+ expect(
+ overflow.scrollWidth,
+ `Horizontal overflow offenders: ${JSON.stringify(overflow.offenders)}`
+ ).toBeLessThanOrEqual(overflow.clientWidth + 2);
+}
+
+async function assertVisibleInteractiveControlsHaveNames(page: Page) {
+ const missingNames = await page.evaluate(() => {
+ const selector = [
+ 'a[href]',
+ 'button',
+ 'input:not([type="hidden"])',
+ 'select',
+ 'textarea',
+ '[role="button"]',
+ '[role="combobox"]',
+ '[role="switch"]',
+ '[role="tab"]',
+ ].join(',');
+
+ function isVisible(element: Element) {
+ const rect = element.getBoundingClientRect();
+ const style = window.getComputedStyle(element);
+ return (
+ rect.width > 0 &&
+ rect.height > 0 &&
+ style.visibility !== 'hidden' &&
+ !element.classList.contains('sr-only')
+ );
+ }
+
+ function labelledByText(element: Element) {
+ const labelledBy = element.getAttribute('aria-labelledby');
+ if (!labelledBy) return '';
+
+ return labelledBy
+ .split(/\s+/)
+ .map((id) => document.getElementById(id)?.textContent?.trim() ?? '')
+ .join(' ')
+ .trim();
+ }
+
+ function associatedLabelText(element: Element) {
+ const id = element.getAttribute('id');
+ if (!id) return '';
+ return document.querySelector(`label[for="${CSS.escape(id)}"]`)?.textContent?.trim() ?? '';
+ }
+
+ return Array.from(document.querySelectorAll(selector))
+ .filter((element) => isVisible(element))
+ .filter((element) => !element.closest('[aria-hidden="true"]'))
+ .map((element) => {
+ const text = element.textContent?.trim() ?? '';
+ const name =
+ element.getAttribute('aria-label')?.trim() ||
+ labelledByText(element) ||
+ associatedLabelText(element) ||
+ element.getAttribute('title')?.trim() ||
+ element.getAttribute('placeholder')?.trim() ||
+ text;
+
+ return {
+ hasName: name.length > 0,
+ html: element.outerHTML.slice(0, 240),
+ };
+ })
+ .filter((entry) => !entry.hasName)
+ .map((entry) => entry.html);
+ });
+
+ expect(missingNames).toEqual([]);
+}
+
+async function assertMobileTouchTargets(page: Page) {
+ const undersizedTargets = await page.evaluate(() => {
+ const selector = [
+ 'a[href]',
+ 'button',
+ 'input:not([type="hidden"])',
+ 'select',
+ 'textarea',
+ '[role="button"]',
+ '[role="combobox"]',
+ '[role="switch"]',
+ '[role="tab"]',
+ ].join(',');
+
+ function isVisible(element: Element) {
+ const rect = element.getBoundingClientRect();
+ const style = window.getComputedStyle(element);
+ return (
+ rect.width > 0 &&
+ rect.height > 0 &&
+ style.visibility !== 'hidden' &&
+ !element.classList.contains('sr-only')
+ );
+ }
+
+ return Array.from(document.querySelectorAll(selector))
+ .filter((element) => isVisible(element))
+ .filter((element) => !element.closest('[aria-hidden="true"]'))
+ .map((element) => {
+ const rect = element.getBoundingClientRect();
+ return {
+ ariaLabel: element.getAttribute('aria-label'),
+ height: Math.round(rect.height),
+ html: element.outerHTML.slice(0, 500),
+ text: element.textContent?.trim(),
+ title: element.getAttribute('title'),
+ width: Math.round(rect.width),
+ };
+ })
+ .filter((entry) => entry.height < 32 || entry.width < 32);
+ });
+
+ expect(undersizedTargets).toEqual([]);
+}
+
+async function assertKeyboardFocusLandsOnVisibleControl(page: Page) {
+ await page.keyboard.press('Tab');
+
+ const focused = await page.evaluate(() => {
+ const element = document.activeElement;
+ if (!element || element === document.body) return null;
+ const rect = element.getBoundingClientRect();
+ return {
+ height: rect.height,
+ tagName: element.tagName,
+ width: rect.width,
+ };
+ });
+
+ expect(focused).not.toBeNull();
+ expect(focused?.width).toBeGreaterThan(0);
+ expect(focused?.height).toBeGreaterThan(0);
+}
+
+async function assertFocusRemainsInsideDialog(page: Page, dialogLabel: string) {
+ const dialog = page.locator('[role="dialog"]').last();
+ await expect(dialog).toBeVisible({ timeout: 5_000 });
+ const focusable = dialog.locator(
+ [
+ 'a[href]',
+ 'button:not([disabled])',
+ 'input:not([type="hidden"]):not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ '[role="button"]:not([aria-disabled="true"])',
+ '[role="combobox"]:not([aria-disabled="true"])',
+ '[role="tab"]:not([aria-disabled="true"])',
+ '[tabindex]:not([tabindex="-1"])',
+ ].join(',')
+ );
+ await focusable.first().focus();
+
+ for (let i = 0; i < 8; i += 1) {
+ await page.keyboard.press('Tab');
+ const focusState = await page.evaluate((step) => {
+ const active = document.activeElement;
+ const activeElement = active instanceof HTMLElement ? active : null;
+ const dialogElement = Array.from(document.querySelectorAll('[role="dialog"]')).find(
+ (element) => {
+ const rect = element.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+ }
+ );
+
+ return {
+ activeHtml: activeElement?.outerHTML.slice(0, 220) ?? null,
+ activeText: activeElement?.textContent?.trim().slice(0, 120) ?? null,
+ dialogCount: document.querySelectorAll('[role="dialog"]').length,
+ hasVisibleDialog: Boolean(dialogElement),
+ inside: Boolean(active && dialogElement?.contains(active)),
+ step,
+ };
+ }, i + 1);
+
+ expect(
+ focusState.inside,
+ `${dialogLabel} should keep keyboard focus inside its dialog/drawer: ${JSON.stringify(focusState)}`
+ ).toBe(true);
+ }
+}
+
+test.describe('v5 Mantine migration QA gate', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ let createdTaskIds: string[] = [];
+
+ test.beforeEach(async ({ page }) => {
+ await bypassAuth(page);
+ await mockAgentStatus(page);
+ await mockEmptyTaskChatSessions(page);
+ });
+
+ test.afterEach(async ({ page }) => {
+ for (const taskId of createdTaskIds) {
+ await deleteTask(page, taskId).catch(() => {});
+ }
+ createdTaskIds = [];
+ await cleanupRoutes(page);
+ });
+
+ test('captures desktop visual and accessibility smoke for every current app route', async ({
+ page,
+ }, testInfo) => {
+ test.setTimeout(120_000);
+ const browserErrors = captureUnexpectedBrowserErrors(page);
+
+ for (const scheme of ['dark', 'light'] as const) {
+ await page.setViewportSize(desktopViewport);
+
+ for (const surface of routeSurfaces) {
+ await page.goto(surface.path, { timeout: 15_000 });
+ await surface.ready(page);
+ await setColorScheme(page, scheme);
+
+ await assertColorScheme(page, scheme);
+ await assertNoLegacyPrimitiveSlots(page);
+ await assertNoHorizontalOverflow(page);
+ await assertVisibleInteractiveControlsHaveNames(page);
+ await assertKeyboardFocusLandsOnVisibleControl(page);
+ await attachViewportScreenshot(page, testInfo, `route-${surface.name}-desktop`, scheme);
+ }
+ }
+
+ expect(browserErrors).toEqual([]);
+ });
+
+ test('covers migrated overlays, focus traps, and form controls', async ({ page }, testInfo) => {
+ test.setTimeout(90_000);
+ const browserErrors = captureUnexpectedBrowserErrors(page);
+ const taskTitle = `Mantine QA Detail ${Date.now()}`;
+ const task = await seedTestTask(page, {
+ title: taskTitle,
+ status: 'todo',
+ priority: 'high',
+ description: 'Task seeded for the Mantine migration QA gate.',
+ });
+ const taskId = getSeededTaskId(task);
+ if (taskId) createdTaskIds.push(taskId);
+
+ await page.setViewportSize(desktopViewport);
+ await page.goto('/', { timeout: 15_000 });
+ await expect(page.getByRole('region', { name: 'To Do' })).toBeVisible({
+ timeout: 15_000,
+ });
+ await setColorScheme(page, 'dark');
+
+ await page.getByRole('button', { name: /New Task/i }).click();
+ await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
+ await assertNoLegacyPrimitiveSlots(page);
+ await assertVisibleInteractiveControlsHaveNames(page);
+ await assertFocusRemainsInsideDialog(page, 'Create task');
+ await attachViewportScreenshot(page, testInfo, 'overlay-create-task-desktop', 'dark');
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5_000 });
+
+ await page.getByRole('button', { name: 'Settings' }).click();
+ const settingsDialog = page.getByRole('dialog', { name: 'Settings' });
+ await expect(settingsDialog).toBeVisible({ timeout: 5_000 });
+ await settingsDialog.getByRole('tab', { name: 'Board' }).click();
+ await assertNoLegacyPrimitiveSlots(page);
+ await assertVisibleInteractiveControlsHaveNames(page);
+ await assertFocusRemainsInsideDialog(page, 'Settings');
+ await attachViewportScreenshot(page, testInfo, 'overlay-settings-desktop', 'dark');
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5_000 });
+
+ await page.getByRole('button', { name: 'Search' }).click();
+ await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
+ await expect(page.getByRole('textbox', { name: 'Search tasks and docs' })).toBeVisible();
+ await assertNoLegacyPrimitiveSlots(page);
+ await assertVisibleInteractiveControlsHaveNames(page);
+ await assertFocusRemainsInsideDialog(page, 'Search');
+ await attachViewportScreenshot(page, testInfo, 'overlay-search-desktop', 'dark');
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5_000 });
+
+ await page.getByRole('button', { name: 'Command palette' }).click();
+ await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
+ await expect(page.getByRole('textbox', { name: 'Search commands' })).toBeVisible();
+ await assertNoLegacyPrimitiveSlots(page);
+ await assertVisibleInteractiveControlsHaveNames(page);
+ await assertFocusRemainsInsideDialog(page, 'Command palette');
+ await attachViewportScreenshot(page, testInfo, 'overlay-command-palette-desktop', 'dark');
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5_000 });
+
+ await page.getByRole('heading', { name: taskTitle }).click();
+ await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
+ await expect(page.locator('.mantine-Drawer-content')).toBeVisible();
+ await assertNoLegacyPrimitiveSlots(page);
+ await assertVisibleInteractiveControlsHaveNames(page);
+ await assertFocusRemainsInsideDialog(page, 'Task detail');
+ await attachViewportScreenshot(page, testInfo, 'overlay-task-detail-desktop', 'dark');
+
+ expect(browserErrors).toEqual([]);
+ });
+
+ test('captures mobile board, task detail, settings, and auth/setup smoke', async ({
+ page,
+ }, testInfo) => {
+ test.setTimeout(90_000);
+ const browserErrors = captureUnexpectedBrowserErrors(page);
+ const taskTitle = `Mantine Mobile QA ${Date.now()}`;
+ const task = await seedTestTask(page, {
+ title: taskTitle,
+ status: 'todo',
+ priority: 'medium',
+ });
+ const taskId = getSeededTaskId(task);
+ if (taskId) createdTaskIds.push(taskId);
+
+ await page.setViewportSize(mobileViewport);
+ await page.goto('/', { timeout: 15_000 });
+ await expect(page.getByRole('region', { name: 'To Do' })).toBeVisible({
+ timeout: 15_000,
+ });
+ await setColorScheme(page, 'dark');
+ await assertNoHorizontalOverflow(page);
+ await assertMobileTouchTargets(page);
+ await assertNoLegacyPrimitiveSlots(page);
+ await attachViewportScreenshot(page, testInfo, 'mobile-board', 'dark');
+
+ await page.getByRole('heading', { name: taskTitle }).click();
+ await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
+ await expect(page.locator('.mantine-Drawer-content')).toBeVisible();
+ await assertNoHorizontalOverflow(page);
+ await assertMobileTouchTargets(page);
+ await assertNoLegacyPrimitiveSlots(page);
+ await attachViewportScreenshot(page, testInfo, 'mobile-task-detail', 'dark');
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5_000 });
+
+ await page.getByRole('button', { name: 'Settings' }).click();
+ await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
+ await assertNoHorizontalOverflow(page);
+ await assertMobileTouchTargets(page);
+ await assertNoLegacyPrimitiveSlots(page);
+ await attachViewportScreenshot(page, testInfo, 'mobile-settings', 'dark');
+
+ await cleanupRoutes(page);
+ await page.unrouteAll({ behavior: 'ignoreErrors' });
+ await page.route('**/api/auth/status', (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ authenticated: false,
+ authEnabled: true,
+ needsSetup: true,
+ sessionExpiry: null,
+ }),
+ })
+ );
+
+ await page.goto('/', { timeout: 15_000 });
+ await expect(page.getByRole('heading', { name: 'Choose setup path' })).toBeVisible({
+ timeout: 15_000,
+ });
+ await assertNoHorizontalOverflow(page);
+ await assertMobileTouchTargets(page);
+ await assertNoLegacyPrimitiveSlots(page);
+ await page.getByRole('button', { name: 'Continue to Password' }).click();
+ await expect(page.getByRole('heading', { name: 'Secure Your Board' })).toBeVisible({
+ timeout: 15_000,
+ });
+ await assertNoHorizontalOverflow(page);
+ await assertMobileTouchTargets(page);
+ await assertNoLegacyPrimitiveSlots(page);
+ await attachViewportScreenshot(page, testInfo, 'mobile-auth-setup', 'dark');
+
+ expect(browserErrors).toEqual([]);
+ });
+});
diff --git a/package.json b/package.json
index c62ebc5a..52bf55ba 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",
+ "qa:mantine": "node scripts/check-mantine-qa-gate.mjs",
"test:load:smoke": "k6 run load-tests/k6/smoke.js",
"test:load": "k6 run load-tests/k6/smoke.js && k6 run load-tests/k6/read-load.js && k6 run load-tests/k6/write-load.js && k6 run load-tests/k6/mixed-load.js && k6 run load-tests/k6/ws-stress.js",
"validate:release": "node scripts/validate-release.mjs",
diff --git a/scripts/check-mantine-qa-gate.mjs b/scripts/check-mantine-qa-gate.mjs
new file mode 100644
index 00000000..e4b2c740
--- /dev/null
+++ b/scripts/check-mantine-qa-gate.mjs
@@ -0,0 +1,281 @@
+#!/usr/bin/env node
+import { gzipSync } from 'node:zlib';
+import { readdir, readFile, stat } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+const primitiveWrappers = [
+ 'alert',
+ 'alert-dialog',
+ 'badge',
+ 'button',
+ 'card',
+ 'checkbox',
+ 'dialog',
+ 'input',
+ 'label',
+ 'number-input',
+ 'popover',
+ 'progress',
+ 'scroll-area',
+ 'select',
+ 'sheet',
+ 'skeleton',
+ 'slider',
+ 'switch',
+ 'tabs',
+ 'textarea',
+ 'tooltip',
+];
+
+const allowedCompatibilityInternals = new Set([
+ 'web/src/components/ui/alert-dialog.tsx',
+ 'web/src/components/ui/dialog.tsx',
+ 'web/src/components/ui/sheet.tsx',
+]);
+
+const maxInitialJsGzipBytes = 250 * 1024;
+const maxInitialCssGzipBytes = 64 * 1024;
+const maxLazyChunkGzipBytes = 150 * 1024;
+const maxLazyChunkRawBytes = 550 * 1024;
+
+const checks = [];
+
+function record(status, name, detail = '') {
+ checks.push({ status, name, detail });
+}
+
+function pass(name, detail = '') {
+ record('pass', name, detail);
+}
+
+function fail(name, detail = '') {
+ record('fail', name, detail);
+}
+
+function rel(file) {
+ return path.relative(rootDir, file).split(path.sep).join('/');
+}
+
+async function readJson(relativePath) {
+ return JSON.parse(await readFile(path.join(rootDir, relativePath), 'utf8'));
+}
+
+async function collectFiles(dir, predicate, files = []) {
+ const entries = await readdir(dir, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ await collectFiles(fullPath, predicate, files);
+ } else if (predicate(fullPath)) {
+ files.push(fullPath);
+ }
+ }
+
+ return files;
+}
+
+async function checkPackageSurface() {
+ const packageFiles = ['package.json', 'web/package.json'];
+ const bannedPackages = [/^shadcn$/, /^@radix-ui\/react-/];
+ const offenders = [];
+
+ for (const packageFile of packageFiles) {
+ const manifest = await readJson(packageFile);
+ const dependencyGroups = ['dependencies', 'devDependencies', 'peerDependencies'];
+
+ for (const group of dependencyGroups) {
+ const deps = manifest[group] ?? {};
+ for (const name of Object.keys(deps)) {
+ if (bannedPackages.some((pattern) => pattern.test(name))) {
+ offenders.push(`${packageFile}:${group}:${name}`);
+ }
+ }
+ }
+ }
+
+ if (offenders.length > 0) {
+ fail('dependency cleanup', offenders.join('\n'));
+ return;
+ }
+
+ pass('dependency cleanup', 'No shadcn or direct @radix-ui/react-* packages remain.');
+}
+
+async function checkFeatureWrapperImports() {
+ const componentFiles = await collectFiles(path.join(rootDir, 'web/src/components'), (file) =>
+ file.endsWith('.tsx')
+ );
+ const wrapperAlternation = primitiveWrappers
+ .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+ .join('|');
+ const importPattern = new RegExp(
+ `from\\s+['"](?:@/components/ui/(${wrapperAlternation})|(?:\\.{1,2}/)+ui/(${wrapperAlternation}))['"]`,
+ 'g'
+ );
+ const offenders = [];
+
+ for (const file of componentFiles) {
+ const relative = rel(file);
+ const source = await readFile(file, 'utf8');
+ const matches = Array.from(source.matchAll(importPattern));
+
+ if (matches.length === 0) continue;
+ if (allowedCompatibilityInternals.has(relative)) continue;
+
+ offenders.push(`${relative}: ${matches.map((match) => match[0]).join(', ')}`);
+ }
+
+ if (offenders.length > 0) {
+ fail('active feature wrapper imports', offenders.join('\n'));
+ return;
+ }
+
+ pass(
+ 'active feature wrapper imports',
+ 'No enabled feature component imports legacy primitive compatibility wrappers.'
+ );
+}
+
+async function checkDocsLinks() {
+ const readme = await readFile(path.join(rootDir, 'README.md'), 'utf8');
+ const checklist = await readFile(path.join(rootDir, 'docs/V5-GA-CHECKLIST.md'), 'utf8');
+ const requiredChecklistMarkers = [
+ 'Mantine component-system cleanup gate',
+ 'pnpm qa:mantine',
+ 'pnpm test:e2e -- e2e/mantine-qa-gate.spec.ts',
+ 'visual and accessibility evidence',
+ 'temporary holdouts',
+ ];
+ const missingMarkers = requiredChecklistMarkers.filter((marker) => !checklist.includes(marker));
+
+ if (!readme.includes('docs/V5-GA-CHECKLIST.md')) {
+ fail('v5 GA checklist link', 'README.md does not link docs/V5-GA-CHECKLIST.md.');
+ return;
+ }
+
+ if (missingMarkers.length > 0) {
+ fail('v5 GA checklist content', missingMarkers.join('\n'));
+ return;
+ }
+
+ pass('v5 GA checklist content', 'Mantine cleanup gate is represented in the v5 GA checklist.');
+}
+
+function assetStats(buffer) {
+ return {
+ gzipBytes: gzipSync(buffer).length,
+ rawBytes: buffer.length,
+ };
+}
+
+async function checkBundleOutput() {
+ const distDir = path.join(rootDir, 'web/dist');
+ const assetsDir = path.join(distDir, 'assets');
+ const indexHtml = await readFile(path.join(distDir, 'index.html'), 'utf8').catch(() => null);
+
+ if (!indexHtml) {
+ fail(
+ 'bundle output',
+ 'web/dist/index.html is missing. Run pnpm --filter @veritas-kanban/web build first.'
+ );
+ return;
+ }
+
+ const assets = await readdir(assetsDir).catch(() => null);
+ if (!assets) {
+ fail(
+ 'bundle output',
+ 'web/dist/assets is missing. Run pnpm --filter @veritas-kanban/web build first.'
+ );
+ return;
+ }
+
+ const radixAssets = assets.filter((asset) => /vendor-radix|radix/i.test(asset));
+ if (radixAssets.length > 0) {
+ fail('bundle Radix cleanup', radixAssets.join('\n'));
+ return;
+ }
+
+ const initialAssetRefs = Array.from(
+ indexHtml.matchAll(/(?:src|href)="\/assets\/([^"]+\.(?:js|css))"/g)
+ ).map((match) => match[1]);
+ const initial = {
+ cssGzipBytes: 0,
+ jsGzipBytes: 0,
+ };
+ const oversizedLazyChunks = [];
+
+ for (const asset of assets.filter((name) => /\.(js|css)$/.test(name))) {
+ const file = path.join(assetsDir, asset);
+ const fileStat = await stat(file);
+ if (!fileStat.isFile()) continue;
+
+ const buffer = await readFile(file);
+ const stats = assetStats(buffer);
+ const isInitial = initialAssetRefs.includes(asset);
+
+ if (isInitial && asset.endsWith('.js')) {
+ initial.jsGzipBytes += stats.gzipBytes;
+ } else if (isInitial && asset.endsWith('.css')) {
+ initial.cssGzipBytes += stats.gzipBytes;
+ } else if (
+ asset.endsWith('.js') &&
+ (stats.gzipBytes > maxLazyChunkGzipBytes || stats.rawBytes > maxLazyChunkRawBytes)
+ ) {
+ oversizedLazyChunks.push(
+ `${asset}: ${(stats.rawBytes / 1024).toFixed(1)} KiB raw, ${(stats.gzipBytes / 1024).toFixed(1)} KiB gzip`
+ );
+ }
+ }
+
+ if (initial.jsGzipBytes > maxInitialJsGzipBytes) {
+ fail(
+ 'initial JS budget',
+ `${(initial.jsGzipBytes / 1024).toFixed(1)} KiB gzip exceeds ${maxInitialJsGzipBytes / 1024} KiB.`
+ );
+ return;
+ }
+
+ if (initial.cssGzipBytes > maxInitialCssGzipBytes) {
+ fail(
+ 'initial CSS budget',
+ `${(initial.cssGzipBytes / 1024).toFixed(1)} KiB gzip exceeds ${maxInitialCssGzipBytes / 1024} KiB.`
+ );
+ return;
+ }
+
+ if (oversizedLazyChunks.length > 0) {
+ fail('lazy route chunk budget', oversizedLazyChunks.join('\n'));
+ return;
+ }
+
+ pass(
+ 'bundle budgets',
+ `Initial JS ${(initial.jsGzipBytes / 1024).toFixed(1)} KiB gzip, initial CSS ${(initial.cssGzipBytes / 1024).toFixed(1)} KiB gzip.`
+ );
+}
+
+async function main() {
+ await checkPackageSurface();
+ await checkFeatureWrapperImports();
+ await checkDocsLinks();
+ await checkBundleOutput();
+
+ for (const check of checks) {
+ const prefix = check.status === 'pass' ? 'PASS' : 'FAIL';
+ console.log(`${prefix} ${check.name}${check.detail ? `\n${check.detail}` : ''}`);
+ }
+
+ if (checks.some((check) => check.status === 'fail')) {
+ process.exitCode = 1;
+ }
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});
diff --git a/scripts/validate-release.mjs b/scripts/validate-release.mjs
index 6898534e..6d3825b4 100644
--- a/scripts/validate-release.mjs
+++ b/scripts/validate-release.mjs
@@ -30,6 +30,7 @@ const requiredScripts = [
'build',
'lint',
'lint:budget',
+ 'qa:mantine',
'test:e2e',
'test:load',
'test:load:smoke',
diff --git a/web/src/components/auth/DesktopOnboarding.tsx b/web/src/components/auth/DesktopOnboarding.tsx
index bba970f8..f09e9dde 100644
--- a/web/src/components/auth/DesktopOnboarding.tsx
+++ b/web/src/components/auth/DesktopOnboarding.tsx
@@ -308,20 +308,25 @@ export function DesktopOnboardingPanel({
return (
-
+
v5 Desktop Setup
-
+
Choose setup path
-
+
Start with the board, then layer in agents, remote access, and recovery paths when
they are needed.
@@ -394,7 +399,7 @@ export function DesktopOnboardingPanel({
-
+
{setupModes.map((mode) => {
const Icon = mode.icon;
diff --git a/web/src/components/board/BoardLoadingSkeleton.tsx b/web/src/components/board/BoardLoadingSkeleton.tsx
index 06918d2d..6db13a9f 100644
--- a/web/src/components/board/BoardLoadingSkeleton.tsx
+++ b/web/src/components/board/BoardLoadingSkeleton.tsx
@@ -12,7 +12,7 @@ interface BoardLoadingSkeletonProps {
export function BoardLoadingSkeleton({ columns }: BoardLoadingSkeletonProps) {
return (
-
+
{columns.map((column) => (
v
return (
agent.taskId && onTaskClick?.(agent.taskId)}
>
v
) : data.activeTaskTitle ? (
/* Fallback: single task (no activeAgents array) */
data.activeTask && onTaskClick?.(data.activeTask)}
>
{data.activeTaskTitle}
@@ -316,7 +316,7 @@ function RecentStatusChanges({
Recent Status Changes
@@ -326,7 +326,7 @@ function RecentStatusChanges({
activities.map((activity) => (
activity.taskId && onTaskClick?.(activity.taskId)}
>
+
{/* Search */}
-
+
@@ -104,7 +108,7 @@ export function FilterBar({ filters, onFiltersChange }: FilterBarProps) {
disabled={typesLoading}
data={typeOptions}
aria-label="Filter by type"
- className="w-[160px]"
+ className="w-full sm:w-[160px]"
allowDeselect={false}
/>
@@ -116,7 +120,7 @@ export function FilterBar({ filters, onFiltersChange }: FilterBarProps) {
}
data={agentOptions}
aria-label="Filter by agent"
- className="w-[160px]"
+ className="w-full sm:w-[160px]"
allowDeselect={false}
/>
diff --git a/web/src/components/board/KanbanBoard.tsx b/web/src/components/board/KanbanBoard.tsx
index a67064ac..c36930e7 100644
--- a/web/src/components/board/KanbanBoard.tsx
+++ b/web/src/components/board/KanbanBoard.tsx
@@ -238,7 +238,7 @@ export function KanbanBoard() {
return (
<>
-
+
{!isSelecting && (
}
>
Select
@@ -260,9 +260,9 @@ export function KanbanBoard() {
{featureSettings.board.showArchiveSuggestions &&
}
-
+
{featureSettings.board.enableDragAndDrop && canWriteTasks ? (
@@ -273,7 +273,11 @@ export function KanbanBoard() {
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
-
+
{COLUMNS.map((column) => (
) : (
-
+
{COLUMNS.map((column) => (
-
-
+
+
diff --git a/web/src/components/dashboard/Dashboard.tsx b/web/src/components/dashboard/Dashboard.tsx
index 0571cb95..c6c655b8 100644
--- a/web/src/components/dashboard/Dashboard.tsx
+++ b/web/src/components/dashboard/Dashboard.tsx
@@ -219,7 +219,7 @@ export function Dashboard() {
{/* Agent Operations Row */}
{isLoading ? (
-
+
{[...Array(3)].map((_, i) => (
))}
@@ -233,7 +233,7 @@ export function Dashboard() {
) : (
-
+
{/* Tokens Card */}
{widgets.showTokenUsage && (
+
{widgets.showAgentComparison &&
}
{widgets.showStatusTimeline && (
@@ -321,7 +321,7 @@ export function Dashboard() {
{/* Cost per Task + Agent Utilization */}
{(widgets.showCostPerTask || widgets.showAgentUtilization) && (
-
+
{/* Cost per Task */}
{widgets.showCostPerTask && (
@@ -427,7 +427,7 @@ export function Dashboard() {
{/* New Dashboard Widgets */}
{(widgets.showWallTime || widgets.showSessionMetrics || widgets.showActivityClock) && (
-
+
{widgets.showWallTime &&
}
{widgets.showSessionMetrics &&
}
{widgets.showActivityClock &&
}
@@ -435,7 +435,7 @@ export function Dashboard() {
)}
{(widgets.showWhereTimeWent || widgets.showHourlyActivity) && (
-
+
{widgets.showWhereTimeWent && }
{widgets.showHourlyActivity && }
diff --git a/web/src/components/dashboard/DashboardFilterBar.tsx b/web/src/components/dashboard/DashboardFilterBar.tsx
index 2c90ab66..85d97f7b 100644
--- a/web/src/components/dashboard/DashboardFilterBar.tsx
+++ b/web/src/components/dashboard/DashboardFilterBar.tsx
@@ -54,14 +54,14 @@ export function DashboardFilterBar({
const isCustomActive = period === 'custom';
return (
-
+
{/* Left: Preset Pills */}
-
+
{PERIOD_PRESETS.map((preset) => (
handlePresetClick(preset.value)}
>
{preset.label}
@@ -70,11 +70,16 @@ export function DashboardFilterBar({
{/* Right: Project + Custom Range + Export */}
-
+
{/* Project Selector */}
onProjectChange(value === 'all' ? undefined : (value ?? undefined))}
@@ -85,14 +90,14 @@ export function DashboardFilterBar({
/>
{/* Custom Date Range */}
-
+
Custom:
setCustomFrom(e.target.value)}
@@ -104,14 +109,14 @@ export function DashboardFilterBar({
setCustomTo(e.target.value)}
min={customFrom || undefined}
/>
}
+ size="sm"
+ leftSection={ }
onClick={onExportClick}
>
Export
diff --git a/web/src/components/dashboard/WallTimeToggle.tsx b/web/src/components/dashboard/WallTimeToggle.tsx
index 764af21b..4da676c6 100644
--- a/web/src/components/dashboard/WallTimeToggle.tsx
+++ b/web/src/components/dashboard/WallTimeToggle.tsx
@@ -84,14 +84,14 @@ export function WallTimeToggle({ period }: WallTimeToggleProps) {
}
>
-
-
+
+
setShowActive(!showActive)}
leftSection={
diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx
index 74d42d79..88968af4 100644
--- a/web/src/components/layout/Header.tsx
+++ b/web/src/components/layout/Header.tsx
@@ -193,10 +193,10 @@ export function Header() {
-
+
}
onClick={openCreateDialog}
disabled={!canCreateTask}
@@ -287,7 +287,7 @@ export function Header() {
}
onClick={() =>
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
diff --git a/web/src/components/layout/SystemHealthBar.tsx b/web/src/components/layout/SystemHealthBar.tsx
index 8289654a..34c04153 100644
--- a/web/src/components/layout/SystemHealthBar.tsx
+++ b/web/src/components/layout/SystemHealthBar.tsx
@@ -114,7 +114,7 @@ export function SystemHealthBar() {
{/* Main bar */}
setExpanded(!expanded)}
aria-expanded={expanded}
aria-controls="system-health-details"
diff --git a/web/src/components/layout/UserMenu.tsx b/web/src/components/layout/UserMenu.tsx
index 81d2f9be..dc9b41cf 100644
--- a/web/src/components/layout/UserMenu.tsx
+++ b/web/src/components/layout/UserMenu.tsx
@@ -103,12 +103,14 @@ export function UserMenu({ onOpenSecuritySettings, onOpenIdentitySettings }: Use
}
rightSection={
}
title="Session menu"
+ className="min-h-8"
onClick={() => setOpen((current) => !current)}
>
diff --git a/web/src/components/scoring/ScoringProfiles.tsx b/web/src/components/scoring/ScoringProfiles.tsx
index 875414d3..783ef151 100644
--- a/web/src/components/scoring/ScoringProfiles.tsx
+++ b/web/src/components/scoring/ScoringProfiles.tsx
@@ -363,6 +363,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
Name
setDraft((current) => ({ ...current, name: event.target.value }))
@@ -373,6 +374,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
Composite Method
setDraft((current) => ({
@@ -392,6 +394,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {