mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
Add Mantine QA release gate
This commit is contained in:
parent
4243a86d28
commit
e6c15c036d
24 changed files with 1201 additions and 78 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
56
docs/V5-GA-CHECKLIST.md
Normal 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.
|
||||
|
|
@ -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
623
e2e/mantine-qa-gate.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
281
scripts/check-mantine-qa-gate.mjs
Normal file
281
scripts/check-mantine-qa-gate.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -30,6 +30,7 @@ const requiredScripts = [
|
|||
'build',
|
||||
'lint',
|
||||
'lint:budget',
|
||||
'qa:mantine',
|
||||
'test:e2e',
|
||||
'test:load',
|
||||
'test:load:smoke',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`} />
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue