Add Mantine QA release gate

This commit is contained in:
Brad Groux 2026-06-01 14:17:56 -07:00
parent 4243a86d28
commit e6c15c036d
24 changed files with 1201 additions and 78 deletions

View file

@ -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.

View file

@ -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:

56
docs/V5-GA-CHECKLIST.md Normal file
View file

@ -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.

View file

@ -1604,7 +1604,7 @@ curl -X POST .../tasks/<id>/comments \
<div class="stack-item fade-in">
<span class="stack-icon">⚛️</span>
<h3>Frontend</h3>
<p>React 19 + TypeScript<br>Vite 7 + Tailwind CSS<br>shadcn/ui components</p>
<p>React 19 + TypeScript<br>Vite + Tailwind CSS<br>Mantine UI foundation</p>
</div>
<div class="stack-item fade-in fade-in-delay-1">
<span class="stack-icon">🟢</span>

623
e2e/mantine-qa-gate.spec.ts Normal file
View file

@ -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<string, unknown>) {
const directId = task.id;
const dataId =
task.data && typeof task.data === 'object' ? (task.data as Record<string, unknown>).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<HTMLElement>('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([]);
});
});

View file

@ -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",

View file

@ -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);
});

View file

@ -30,6 +30,7 @@ const requiredScripts = [
'build',
'lint',
'lint:budget',
'qa:mantine',
'test:e2e',
'test:load',
'test:load:smoke',

View file

@ -308,20 +308,25 @@ export function DesktopOnboardingPanel({
return (
<div
className={cn(
'grid w-full gap-6 text-foreground lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]',
'grid w-full max-w-full min-w-0 gap-6 text-foreground lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]',
compact ? 'max-w-5xl' : 'max-w-6xl'
)}
>
<section className="space-y-5">
<section className="min-w-0 space-y-5">
<div className="space-y-3">
<Badge variant="outline" color="cyan" tt="none">
v5 Desktop Setup
</Badge>
<div className="space-y-2">
<h1 className={cn('font-bold tracking-normal', compact ? 'text-2xl' : 'text-3xl')}>
<h1
className={cn(
'break-words font-bold tracking-normal',
compact ? 'text-2xl' : 'text-3xl'
)}
>
Choose setup path
</h1>
<p className="max-w-xl text-sm leading-6 text-muted-foreground">
<p className="max-w-xl break-words text-sm leading-6 text-muted-foreground">
Start with the board, then layer in agents, remote access, and recovery paths when
they are needed.
</p>
@ -394,7 +399,7 @@ export function DesktopOnboardingPanel({
</div>
</section>
<section className="space-y-4">
<section className="min-w-0 space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
{setupModes.map((mode) => {
const Icon = mode.icon;

View file

@ -12,7 +12,7 @@ interface BoardLoadingSkeletonProps {
export function BoardLoadingSkeleton({ columns }: BoardLoadingSkeletonProps) {
return (
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{columns.map((column) => (
<div
key={column.id}

View file

@ -219,7 +219,7 @@ function AgentStatusPanel({ onTaskClick }: { onTaskClick?: (taskId: string) => v
return (
<button
key={agent.agent || i}
className="flex items-start gap-2 w-full text-left px-2 py-1.5 rounded-md hover:bg-muted/50 transition-colors"
className="flex min-h-8 items-start gap-2 w-full text-left px-2 py-1.5 rounded-md hover:bg-muted/50 transition-colors"
onClick={() => agent.taskId && onTaskClick?.(agent.taskId)}
>
<span
@ -251,7 +251,7 @@ function AgentStatusPanel({ onTaskClick }: { onTaskClick?: (taskId: string) => v
) : data.activeTaskTitle ? (
/* Fallback: single task (no activeAgents array) */
<button
className="text-xs font-medium leading-snug text-left hover:underline cursor-pointer w-full"
className="min-h-8 text-xs font-medium leading-snug text-left hover:underline cursor-pointer w-full"
onClick={() => data.activeTask && onTaskClick?.(data.activeTask)}
>
{data.activeTaskTitle}
@ -316,7 +316,7 @@ function RecentStatusChanges({
<div className="rounded-lg border bg-card p-3 min-h-[220px]">
<button
onClick={onOpenActivityLog}
className="flex items-center gap-1.5 text-[10px] font-medium text-muted-foreground mb-2 uppercase tracking-wider hover:text-foreground transition-colors group w-full text-left"
className="mb-2 flex min-h-8 w-full items-center gap-1.5 text-left text-[10px] font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground group"
>
Recent Status Changes
<ExternalLink className="w-2.5 h-2.5 opacity-0 group-hover:opacity-100 transition-opacity" />
@ -326,7 +326,7 @@ function RecentStatusChanges({
activities.map((activity) => (
<button
key={activity.id}
className="flex items-center gap-1.5 text-[11px] w-full text-left hover:bg-muted/50 rounded px-1 py-0.5 transition-colors"
className="flex min-h-8 items-center gap-1.5 text-[11px] w-full text-left hover:bg-muted/50 rounded px-1 py-0.5 transition-colors"
onClick={() => activity.taskId && onTaskClick?.(activity.taskId)}
>
<div

View file

@ -52,9 +52,13 @@ export function FilterBar({ filters, onFiltersChange }: FilterBarProps) {
};
return (
<div className="flex items-center gap-3" role="search" aria-label="Filter tasks">
<div
className="flex w-full flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:gap-3"
role="search"
aria-label="Filter tasks"
>
{/* Search */}
<div className="relative flex-1 max-w-sm">
<div className="relative w-full sm:max-w-sm sm:flex-1">
<TextInput
id="task-search"
aria-label="Search tasks"
@ -88,7 +92,7 @@ export function FilterBar({ filters, onFiltersChange }: FilterBarProps) {
disabled={projectsLoading}
data={projectOptions}
aria-label="Filter by project"
className="w-[160px]"
className="w-full sm:w-[160px]"
allowDeselect={false}
/>
@ -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}
/>

View file

@ -238,7 +238,7 @@ export function KanbanBoard() {
return (
<>
<div className="flex items-center gap-3 mb-4">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<FilterBar tasks={tasks || []} filters={filters} onFiltersChange={setFilters} />
{!isSelecting && (
<Button
@ -247,7 +247,7 @@ export function KanbanBoard() {
onClick={toggleSelecting}
disabled={!canWriteTasks}
title={canWriteTasks ? 'Select tasks' : 'Task write permission required'}
className="text-muted-foreground shrink-0"
className="min-h-8 shrink-0 self-start text-muted-foreground sm:self-auto"
leftSection={<CheckSquare className="h-4 w-4" aria-hidden="true" />}
>
Select
@ -260,9 +260,9 @@ export function KanbanBoard() {
{featureSettings.board.showArchiveSuggestions && <ArchiveSuggestionBanner />}
<FeatureErrorBoundary fallbackTitle="Board failed to render">
<div className="grid grid-cols-5 gap-4">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-5">
<section
className="col-span-4"
className="min-w-0 xl:col-span-4"
aria-label={`Kanban board, ${filteredTasks.length} tasks`}
>
{featureSettings.board.enableDragAndDrop && canWriteTasks ? (
@ -273,7 +273,11 @@ export function KanbanBoard() {
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-4 gap-4" role="group" aria-label="Kanban columns">
<div
className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4"
role="group"
aria-label="Kanban columns"
>
{COLUMNS.map((column) => (
<KanbanColumn
key={column.id}
@ -293,7 +297,11 @@ export function KanbanBoard() {
</DragOverlay>
</DndContext>
) : (
<div className="grid grid-cols-4 gap-4" role="group" aria-label="Kanban columns">
<div
className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4"
role="group"
aria-label="Kanban columns"
>
{COLUMNS.map((column) => (
<KanbanColumn
key={column.id}

View file

@ -118,8 +118,8 @@ export function ActivityClock({ period }: ActivityClockProps) {
w={260}
label="24-hour ring showing when agent state transitions happen. Brighter/thicker segments mean more activity at that hour. Midnight is at top, noon at bottom. Based on status history."
>
<ActionIcon aria-label="Activity clock help" size="xs" variant="subtle">
<Info className="w-3 h-3 text-muted-foreground" />
<ActionIcon aria-label="Activity clock help" size="sm" variant="subtle">
<Info className="h-4 w-4 text-muted-foreground" />
</ActionIcon>
</Tooltip>
</Group>

View file

@ -219,7 +219,7 @@ export function Dashboard() {
{/* Agent Operations Row */}
<div>
{isLoading ? (
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-32 rounded-lg" />
))}
@ -233,7 +233,7 @@ export function Dashboard() {
</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* Tokens Card */}
{widgets.showTokenUsage && (
<StatCard
@ -309,7 +309,7 @@ export function Dashboard() {
{/* Agent Comparison (left) + Agent Activity (right) */}
{(widgets.showAgentComparison || widgets.showStatusTimeline) && (
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{widgets.showAgentComparison && <AgentComparison project={project} />}
{widgets.showStatusTimeline && (
<div className="rounded-lg border bg-card p-4">
@ -321,7 +321,7 @@ export function Dashboard() {
{/* Cost per Task + Agent Utilization */}
{(widgets.showCostPerTask || widgets.showAgentUtilization) && (
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Cost per Task */}
{widgets.showCostPerTask && (
<div className="rounded-lg border bg-card p-4">
@ -427,7 +427,7 @@ export function Dashboard() {
{/* New Dashboard Widgets */}
{(widgets.showWallTime || widgets.showSessionMetrics || widgets.showActivityClock) && (
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{widgets.showWallTime && <WallTimeToggle period={period} />}
{widgets.showSessionMetrics && <SessionMetrics period={period} />}
{widgets.showActivityClock && <ActivityClock period={period} />}
@ -435,7 +435,7 @@ export function Dashboard() {
)}
{(widgets.showWhereTimeWent || widgets.showHourlyActivity) && (
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{widgets.showWhereTimeWent && <WhereTimeWent period={period} />}
{widgets.showHourlyActivity && <HourlyActivityChart period={period} />}
</div>

View file

@ -54,14 +54,14 @@ export function DashboardFilterBar({
const isCustomActive = period === 'custom';
return (
<Group className="w-full border-b pb-4" gap="sm" justify="space-between" wrap="nowrap">
<Group className="w-full border-b pb-4" gap="sm" justify="space-between" wrap="wrap">
{/* Left: Preset Pills */}
<Group gap={6} className="shrink-0" wrap="nowrap">
<Group gap={6} className="min-w-0 shrink" wrap="wrap">
{PERIOD_PRESETS.map((preset) => (
<Button
key={preset.value}
variant={isPresetActive(preset.value) ? 'filled' : 'subtle'}
size="xs"
size="sm"
onClick={() => handlePresetClick(preset.value)}
>
{preset.label}
@ -70,11 +70,16 @@ export function DashboardFilterBar({
</Group>
{/* Right: Project + Custom Range + Export */}
<Group gap="sm" justify="flex-end" className="ml-auto shrink-0" wrap="nowrap">
<Group
gap="sm"
justify="flex-end"
className="w-full min-w-0 sm:ml-auto sm:w-auto sm:shrink-0"
wrap="wrap"
>
{/* Project Selector */}
<Select
aria-label="Dashboard project filter"
size="xs"
size="sm"
w={160}
value={project || 'all'}
onChange={(value) => onProjectChange(value === 'all' ? undefined : (value ?? undefined))}
@ -85,14 +90,14 @@ export function DashboardFilterBar({
/>
{/* Custom Date Range */}
<Group gap={6} wrap="nowrap">
<Group gap={6} wrap="wrap">
<Text size="xs" c="dimmed" className="whitespace-nowrap">
Custom:
</Text>
<TextInput
aria-label="Custom date from"
type="date"
size="xs"
size="sm"
w={130}
value={customFrom}
onChange={(e) => setCustomFrom(e.target.value)}
@ -104,14 +109,14 @@ export function DashboardFilterBar({
<TextInput
aria-label="Custom date to"
type="date"
size="xs"
size="sm"
w={130}
value={customTo}
onChange={(e) => setCustomTo(e.target.value)}
min={customFrom || undefined}
/>
<Button
size="xs"
size="sm"
variant={isCustomActive ? 'filled' : 'outline'}
onClick={handleCustomApply}
disabled={!customFrom || !customTo}
@ -123,8 +128,8 @@ export function DashboardFilterBar({
{/* Export Button */}
<Button
variant="outline"
size="xs"
leftSection={<Download className="h-3 w-3" />}
size="sm"
leftSection={<Download className="h-4 w-4" />}
onClick={onExportClick}
>
Export

View file

@ -84,14 +84,14 @@ export function WallTimeToggle({ period }: WallTimeToggleProps) {
</Stack>
}
>
<ActionIcon aria-label="Wall time help" size="xs" variant="subtle">
<Info className="w-3 h-3 text-muted-foreground" />
<ActionIcon aria-label="Wall time help" size="sm" variant="subtle">
<Info className="h-4 w-4 text-muted-foreground" />
</ActionIcon>
</Tooltip>
</Group>
<Button
variant="subtle"
size="compact-xs"
size="sm"
className="text-muted-foreground"
onClick={() => setShowActive(!showActive)}
leftSection={

View file

@ -193,10 +193,10 @@ export function Header() {
<WebSocketIndicator />
</Group>
<Group gap="xs" wrap="nowrap" role="toolbar" aria-label="Board actions">
<Group gap="xs" wrap="wrap" role="toolbar" aria-label="Board actions" className="min-w-0">
<Button
variant="filled"
size="xs"
size="sm"
leftSection={<Plus className="h-4 w-4" aria-hidden="true" />}
onClick={openCreateDialog}
disabled={!canCreateTask}
@ -287,7 +287,7 @@ export function Header() {
<Button
variant="subtle"
color="gray"
size="xs"
size="sm"
leftSection={<Search className="h-4 w-4" aria-hidden="true" />}
onClick={() =>
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))

View file

@ -114,7 +114,7 @@ export function SystemHealthBar() {
<div className={`border-b border-border ${config.barClass}`}>
{/* Main bar */}
<button
className="flex h-7 w-full items-center justify-center gap-2 px-4 text-xs cursor-pointer select-none transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset"
className="flex min-h-8 w-full items-center justify-center gap-2 px-4 text-xs cursor-pointer select-none transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-controls="system-health-details"

View file

@ -103,12 +103,14 @@ export function UserMenu({ onOpenSecuritySettings, onOpenIdentitySettings }: Use
<Button
variant="subtle"
color="gray"
size="xs"
size="sm"
aria-label="Session menu"
leftSection={<Lock className="h-4 w-4 text-emerald-500" aria-hidden="true" />}
rightSection={
<ChevronDown className="h-3 w-3 text-muted-foreground" aria-hidden="true" />
}
title="Session menu"
className="min-h-8"
onClick={() => setOpen((current) => !current)}
>
<Text span size="xs" c="dimmed" className="hidden sm:inline">

View file

@ -363,6 +363,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
<div className="space-y-2">
<label className="text-sm font-medium">Name</label>
<TextInput
aria-label="Profile name"
value={draft.name}
onChange={(event) =>
setDraft((current) => ({ ...current, name: event.target.value }))
@ -373,6 +374,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
<div className="space-y-2">
<label className="text-sm font-medium">Composite Method</label>
<Select
aria-label="Composite method"
value={draft.compositeMethod}
onChange={(value) =>
setDraft((current) => ({
@ -392,6 +394,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
<div className="space-y-2">
<label className="text-sm font-medium">Description</label>
<Textarea
aria-label="Profile description"
value={draft.description || ''}
onChange={(event) =>
setDraft((current) => ({ ...current, description: event.target.value }))
@ -430,6 +433,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
<div className="flex items-center justify-between gap-3">
<div className="grid flex-1 gap-3 lg:grid-cols-[1fr_180px_120px]">
<TextInput
aria-label={`Scorer ${index + 1} name`}
value={scorer.name}
onChange={(event) =>
updateScorer(index, (current) => ({
@ -440,6 +444,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
disabled={selectedProfile?.builtIn}
/>
<Select
aria-label={`Scorer ${index + 1} type`}
value={scorer.type}
onChange={(value) => {
if (!value) return;
@ -455,6 +460,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
allowDeselect={false}
/>
<TextInput
aria-label={`Scorer ${index + 1} weight`}
type="number"
min="0"
step="0.1"
@ -491,6 +497,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
Target
</label>
<Select
aria-label={`Scorer ${index + 1} target`}
value={scorer.target || 'output'}
onChange={(value) =>
updateScorer(index, (current) => ({
@ -510,6 +517,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
Keywords
</label>
<TextInput
aria-label={`Scorer ${index + 1} keywords`}
value={scorer.keywords.join(', ')}
onChange={(event) =>
updateScorer(index, (current) => ({
@ -532,6 +540,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
Regex Pattern
</label>
<TextInput
aria-label={`Scorer ${index + 1} regex pattern`}
value={scorer.pattern}
onChange={(event) =>
updateScorer(index, (current) => ({
@ -547,6 +556,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
Flags
</label>
<TextInput
aria-label={`Scorer ${index + 1} regex flags`}
value={scorer.flags || ''}
onChange={(event) =>
updateScorer(index, (current) => ({
@ -567,6 +577,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
Value Path
</label>
<TextInput
aria-label={`Scorer ${index + 1} value path`}
value={scorer.valuePath}
onChange={(event) =>
updateScorer(index, (current) => ({
@ -579,6 +590,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
</div>
<div className="grid gap-3 sm:grid-cols-2">
<TextInput
aria-label={`Scorer ${index + 1} minimum value`}
type="number"
placeholder="Min"
value={scorer.min ?? ''}
@ -594,6 +606,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
disabled={selectedProfile?.builtIn}
/>
<TextInput
aria-label={`Scorer ${index + 1} maximum value`}
type="number"
placeholder="Max"
value={scorer.max ?? ''}
@ -618,6 +631,7 @@ export function ScoringProfiles({ onBack }: ScoringProfilesProps) {
Expression
</label>
<Textarea
aria-label={`Scorer ${index + 1} expression`}
rows={3}
value={scorer.expression}
onChange={(event) =>

View file

@ -173,6 +173,7 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
const { debouncedUpdate } = useDebouncedFeatureUpdate();
const settingsFileInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const dialogContentRef = useRef<HTMLDivElement>(null);
const contentAreaRef = useRef<HTMLDivElement>(null);
const firstTabButtonRef = useRef<HTMLButtonElement>(null);
const [resetAllOpen, setResetAllOpen] = useState(false);
@ -301,6 +302,53 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
[activeTab, canUseTab]
);
const handleDialogKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'Tab') return;
const container = dialogContentRef.current;
if (!container) return;
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(
[
'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(',')
)
).filter((element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden';
});
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (!active || !container.contains(active)) {
e.preventDefault();
first.focus();
return;
}
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}, []);
const renderTab = () => {
return (
<Suspense fallback={<TabSkeleton />}>
@ -386,13 +434,16 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
size={800}
padding={0}
centered
trapFocus
returnFocus
closeButtonProps={{ 'aria-label': 'Close settings' }}
styles={{
content: { height: '85vh', overflow: 'hidden' },
body: { height: '100%', padding: 0 },
}}
>
<ErrorBoundary level="section">
<div className="flex h-full min-h-0">
<div ref={dialogContentRef} className="flex h-full min-h-0" onKeyDown={handleDialogKeyDown}>
{/* Sidebar Tabs — hidden on narrow screens, shown as dropdown instead */}
<div className="hidden sm:flex flex-col w-48 border-r bg-muted/30 py-4">
<div className="px-4 pb-3">
@ -499,9 +550,10 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
}}
data={mobileTabOptions}
aria-label="Select settings section"
size="xs"
size="sm"
checkIconPosition="right"
className="w-40"
styles={{ input: { minHeight: '2rem' } }}
/>
</div>

View file

@ -57,7 +57,7 @@ export function WebSocketIndicator() {
<Popover position="bottom-end">
<Popover.Target>
<UnstyledButton
className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer select-none rounded px-1.5 py-1 transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex min-h-8 min-w-8 items-center justify-center gap-1 rounded px-1.5 py-1 text-xs text-muted-foreground cursor-pointer select-none transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={label}
>
<Box component="span" className={`inline-block h-2 w-2 rounded-full ${dotClass}`} />

View file

@ -98,39 +98,94 @@ export const veritasMantineTheme = createTheme({
defaultProps: {
radius: 'sm',
},
styles: {
root: {
minHeight: '2rem',
minWidth: '2rem',
},
},
},
ActionIcon: {
defaultProps: {
radius: 'sm',
variant: 'subtle',
},
},
Modal: {
defaultProps: {
radius: 'md',
centered: true,
overlayProps: { blur: 2, opacity: 0.45 },
styles: {
root: {
minHeight: '2rem',
minWidth: '2rem',
},
},
},
Drawer: {
defaultProps: {
overlayProps: { blur: 2, opacity: 0.35 },
},
},
TextInput: {
defaultProps: {
radius: 'sm',
},
},
Textarea: {
defaultProps: {
radius: 'sm',
CloseButton: {
styles: {
root: {
minHeight: '2rem',
minWidth: '2rem',
},
},
},
Select: {
defaultProps: {
radius: 'sm',
},
styles: {
input: {
minHeight: '2rem',
},
},
},
TextInput: {
defaultProps: {
radius: 'sm',
},
styles: {
input: {
minHeight: '2rem',
},
},
},
PasswordInput: {
styles: {
input: {
minHeight: '2rem',
},
},
},
Textarea: {
defaultProps: {
radius: 'sm',
},
styles: {
input: {
minHeight: '2rem',
},
},
},
Tabs: {
styles: {
tab: {
minHeight: '2rem',
},
},
},
Modal: {
defaultProps: {
radius: 'md',
centered: true,
trapFocus: true,
returnFocus: true,
closeButtonProps: { 'aria-label': 'Close dialog' },
overlayProps: { blur: 2, opacity: 0.45 },
},
},
Drawer: {
defaultProps: {
trapFocus: true,
returnFocus: true,
closeButtonProps: { 'aria-label': 'Close panel' },
overlayProps: { blur: 2, opacity: 0.35 },
},
},
},
});