From e6c15c036db5a2089313fe3cd7ea8a13143169c5 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:17:56 -0700 Subject: [PATCH] Add Mantine QA release gate --- README.md | 1 + docs/UI-MANTINE-MIGRATION.md | 29 +- docs/V5-GA-CHECKLIST.md | 56 ++ docs/index.html | 2 +- e2e/mantine-qa-gate.spec.ts | 623 ++++++++++++++++++ package.json | 1 + scripts/check-mantine-qa-gate.mjs | 281 ++++++++ scripts/validate-release.mjs | 1 + web/src/components/auth/DesktopOnboarding.tsx | 15 +- .../components/board/BoardLoadingSkeleton.tsx | 2 +- web/src/components/board/BoardSidebar.tsx | 8 +- web/src/components/board/FilterBar.tsx | 14 +- web/src/components/board/KanbanBoard.tsx | 20 +- .../components/dashboard/ActivityClock.tsx | 4 +- web/src/components/dashboard/Dashboard.tsx | 12 +- .../dashboard/DashboardFilterBar.tsx | 27 +- .../components/dashboard/WallTimeToggle.tsx | 6 +- web/src/components/layout/Header.tsx | 6 +- web/src/components/layout/SystemHealthBar.tsx | 2 +- web/src/components/layout/UserMenu.tsx | 4 +- .../components/scoring/ScoringProfiles.tsx | 14 + .../components/settings/SettingsDialog.tsx | 56 +- .../components/shared/WebSocketIndicator.tsx | 2 +- web/src/theme/mantine-theme.ts | 93 ++- 24 files changed, 1201 insertions(+), 78 deletions(-) create mode 100644 docs/V5-GA-CHECKLIST.md create mode 100644 e2e/mantine-qa-gate.spec.ts create mode 100644 scripts/check-mantine-qa-gate.mjs 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 (