From 0fb4621bc6c6af9483df1dc9bb0bfe80f6816431 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:55:54 -0500 Subject: [PATCH 1/7] fix: unify Settings popout geometry and nested focus --- docs/UI-CONTRACT.md | 4 +- docs/audits/SETTINGS-POPOUTS-1436.md | 15 ++ docs/design/OVERLAY-CONTRACT.md | 126 +++++++------- e2e/helpers/settings-popout-fixtures.ts | 132 +++++++++++++++ e2e/settings-popouts.spec.ts | 157 ++++++++++++++++++ .../settings-dialog-mantine.test.tsx | 2 +- .../components/settings/SettingsDialog.tsx | 105 +++--------- .../components/settings/SortableListItem.tsx | 27 +-- .../settings/shared/SectionHeader.tsx | 23 +-- .../components/settings/tabs/AgentsTab.tsx | 21 ++- .../components/settings/tabs/GeneralTab.tsx | 23 +-- .../settings/tabs/MaintenanceTab.tsx | 23 +-- .../components/settings/tabs/SecurityTab.tsx | 59 +++---- .../settings/tabs/SkillRiskDashboardPanel.tsx | 31 ++-- .../settings/tabs/TemplateComponents.tsx | 35 ++-- .../settings/tabs/ToolPoliciesTab.tsx | 28 ++-- 16 files changed, 541 insertions(+), 270 deletions(-) create mode 100644 docs/audits/SETTINGS-POPOUTS-1436.md create mode 100644 e2e/helpers/settings-popout-fixtures.ts create mode 100644 e2e/settings-popouts.spec.ts diff --git a/docs/UI-CONTRACT.md b/docs/UI-CONTRACT.md index 1efd23b6..c3124e19 100644 --- a/docs/UI-CONTRACT.md +++ b/docs/UI-CONTRACT.md @@ -51,7 +51,9 @@ The light/dark palette is specified in `web/src/theme/ui-contract.ts`; CSS custo ## Settings hierarchy -All Settings destinations use `SettingsPage` for the page heading and description, `SettingsSection` for a named card, and `SettingsGroup` for subordinate content. Field rows use `SettingRow`, `ToggleRow`, or `NumberRow`: one neutral row with consistent label/help spacing and a shared control column. Sections use 12px padding at compact widths and 16px otherwise; groups use 12px. The control column ranges from 160px to 224px on wider screens; selectors fill it and numeric controls use 112px plus their unit. Compact rows stack naturally. Inline forms use the borderless `settings-form` spacing treatment inside their group, not a new colored card. +All Settings destinations use `SettingsPage` for the page heading and description, `SettingsSection` for a named card, and `SettingsGroup` for subordinate content. Field rows use `SettingRow`, `ToggleRow`, or `NumberRow`: one neutral row with consistent label/help spacing and a shared control column. Sections use 12px padding at compact widths and 16px otherwise; groups use 12px. The control column ranges from 10rem to 14rem on wider screens. Text, numeric, and select controls fill the same column; numeric units remain inside the input through its suffix formatter, never outside the aligned border. Compact rows stack naturally. Inline forms use the borderless `settings-form` spacing treatment inside their group, not a new colored card. + +Settings uses the shared authoring overlay with viewport-clamped height. Its content scroller has the shared 1rem inset; navigation scrolls independently when needed. All nested Settings dialogs participate in the same overlay focus stack. Confirmation bodies scroll separately from their fixed action footer and initially focus Cancel; editing forms initially focus their first enabled field. Closing a child restores its invoking control without closing Settings. Feature code must not add a second Tab trap or delayed autofocus that competes with the shared stack. `SettingsGroup` tracks the surrounding settings depth through component boundaries: outer card, inset subgroup, then borderless groups. Empty states and `SettingsNotice` also respect this two-border limit. An empty state keeps its spacing and explanation even when its boundary is omitted. Notices remain labeled semantic surfaces without adding a third border. Normal guidance uses neutral notice treatment; success, warning, error, and blocked tones describe actual state, never a destination, category, profile type, or count alone. diff --git a/docs/audits/SETTINGS-POPOUTS-1436.md b/docs/audits/SETTINGS-POPOUTS-1436.md new file mode 100644 index 00000000..7a794719 --- /dev/null +++ b/docs/audits/SETTINGS-POPOUTS-1436.md @@ -0,0 +1,15 @@ +# Settings popout family + +Issue #1436; parent #1383. + +The Settings root adopts the shared authoring modal, shared header/close geometry, and one primary content scroller with a 1rem inset. Its navigation retains a separate bounded scroller. The previous 85vh shell, local header dimensions, delayed autofocus, and extra hand-written Tab trap are removed. Shared focus trapping remains active. + +All ten nested Settings dialogs migrate together: global reset, section reset, managed-list deletion, repository removal, security reset, agent removal, template deletion, tool-policy editing, cleanup preview, and skill exception. Each uses a shared variant with a separately scrolling body and fixed footer. Confirmations initially focus Cancel; forms focus their first enabled field. Existing permissions and callbacks are unchanged. + +The shared build, web typecheck, changed-source lint, and 40 focused Settings unit checks pass. Specification and standards source reviews found no outstanding implementation findings. The all-child browser check passes both themes at 1180×760 with 16px text and 900×480 with 20px text: eleven openings cover all ten nested dialog implementations, including policy creation and editing. Checks cover root containment, exact 1rem content inset, nested parent inertness, initial focus, footer bounds, Tab containment, Escape, exact opener restoration, and blocked mutations. The browser fixture identifies the desktop shell but is not a packaged-app test. + +Initial browser attempts corrected test setup errors: the shared variant/inert attributes belong to the modal root rather than its content section, the policy creation action is named New Policy, and theme selection must use the persisted application setting. The all-child fixture also required the agent's mandatory args array. These attempts are not passing acceptance evidence. + +Read-only host-preview POSTs have an explicit synthetic response fixture. All other non-read API requests remain blocked and the test observes zero mutations; no reset, deletion, save, cleanup, or launch is performed. + +Rebuilt packaged macOS verification, installed-app replacement, final documentation images/GIFs, and release remain unfinished. diff --git a/docs/design/OVERLAY-CONTRACT.md b/docs/design/OVERLAY-CONTRACT.md index 22d9917c..efa76627 100644 --- a/docs/design/OVERLAY-CONTRACT.md +++ b/docs/design/OVERLAY-CONTRACT.md @@ -23,69 +23,69 @@ The shared provider registers only open overlays, removes unmounted entries, and Line numbers identify the audited opening and may move during migration. A shared primitive alone does not prove the feature's inner scroll, spacing, or keyboard behavior. Each row remains subject to rendered acceptance. -| Consumer | Opening line | Intended variant | Status | -| ------------------------------------------------------------------------------------------------- | -----------: | ---------------- | ---------------------------------------------------- | -| [TemplateEditorDialog.tsx](../../web/src/components/templates/TemplateEditorDialog.tsx) | 188 | authoring | Shared primitive adopted; runtime acceptance pending | -| [TemplatesPage.tsx](../../web/src/components/templates/TemplatesPage.tsx) | 274 | confirm | Shared primitive adopted; runtime acceptance pending | -| [TemplatesPage.tsx](../../web/src/components/templates/TemplatesPage.tsx) | 298 | form | Shared primitive adopted; runtime acceptance pending | -| [SearchDialog.tsx](../../web/src/components/search/SearchDialog.tsx) | 282 | authoring | Migration pending; migrate with nested consumers | -| [AdmissionQueuePanel.tsx](../../web/src/components/digest/AdmissionQueuePanel.tsx) | 315 | form | Migration pending | -| [WorkflowStartDialog.tsx](../../web/src/components/workflows/WorkflowStartDialog.tsx) | 71 | form | Migration pending; migrate with nested consumers | -| [CreateTaskDialog.tsx](../../web/src/components/task/CreateTaskDialog.tsx) | 267 | form | Migration pending; migrate with nested consumers | -| [PolicyManager.tsx](../../web/src/components/policies/PolicyManager.tsx) | 552 | authoring | Shared modal; runtime acceptance pending in #1423 | -| [PolicyManager.tsx](../../web/src/components/policies/PolicyManager.tsx) | 906 | form | Shared modal; runtime acceptance pending in #1423 | -| [TimeTrackingSection.tsx](../../web/src/components/task/TimeTrackingSection.tsx) | 243 | form | Migration pending | -| [BulkActionsBar.tsx](../../web/src/components/board/BulkActionsBar.tsx) | 366 | confirm | Migration pending | -| [ArchiveSuggestionBanner.tsx](../../web/src/components/board/ArchiveSuggestionBanner.tsx) | 92 | confirm | Migration pending | -| [ConflictResolver.tsx](../../web/src/components/task/ConflictResolver.tsx) | 124 | utility | Migration pending | -| [ConflictResolver.tsx](../../web/src/components/task/ConflictResolver.tsx) | 383 | confirm | Migration pending | -| [FilterBar.tsx](../../web/src/components/board/FilterBar.tsx) | 349 | form | Migration pending | -| [FilterBar.tsx](../../web/src/components/board/FilterBar.tsx) | 374 | confirm | Migration pending | -| [ChatPanel.tsx](../../web/src/components/chat/ChatPanel.tsx) | 279 | chat | Migration pending; migrate with nested consumers | -| [ChatPanel.tsx](../../web/src/components/chat/ChatPanel.tsx) | 297 | confirm | Migration pending; migrate with nested consumers | -| [TaskWorkView.tsx](../../web/src/components/task/TaskWorkView.tsx) | 1055 | confirm | Migration pending | -| [ObservationsSection.tsx](../../web/src/components/task/ObservationsSection.tsx) | 118 | confirm | Migration pending | -| [SquadChatPanel.tsx](../../web/src/components/chat/SquadChatPanel.tsx) | 522 | chat | Migration pending; migrate with nested consumers | -| [ReviewPanel.tsx](../../web/src/components/task/ReviewPanel.tsx) | 212 | form | Migration pending | -| [ArtifactPreviewModal.tsx](../../web/src/components/task/ArtifactPreviewModal.tsx) | 112 | authoring | Migration pending | -| [CommandPalette.tsx](../../web/src/components/layout/CommandPalette.tsx) | 293 | form | Migration pending; migrate with nested consumers | -| [SettingsDialog.tsx](../../web/src/components/settings/SettingsDialog.tsx) | 584 | authoring | Migration pending; migrate with nested consumers | -| [SettingsDialog.tsx](../../web/src/components/settings/SettingsDialog.tsx) | 805 | confirm | Migration pending; migrate with nested consumers | -| [PRDialog.tsx](../../web/src/components/task/git/PRDialog.tsx) | 36 | authoring | Migration pending | -| [ActivitySidebar.tsx](../../web/src/components/layout/ActivitySidebar.tsx) | 244 | utility | Migration pending | -| [SortableListItem.tsx](../../web/src/components/settings/SortableListItem.tsx) | 170 | confirm | Migration pending | -| [WorktreeStatus.tsx](../../web/src/components/task/git/WorktreeStatus.tsx) | 374 | form | Migration pending | -| [WorktreeStatus.tsx](../../web/src/components/task/git/WorktreeStatus.tsx) | 404 | confirm | Migration pending | -| [TaskDetailsTab.tsx](../../web/src/components/task/detail/TaskDetailsTab.tsx) | 254 | confirm | Migration pending | -| [KeyboardShortcutsDialog.tsx](../../web/src/components/layout/KeyboardShortcutsDialog.tsx) | 49 | form | Migration pending | -| [ArchiveSidebar.tsx](../../web/src/components/layout/ArchiveSidebar.tsx) | 240 | utility | Migration pending | -| [MobileShell.tsx](../../web/src/components/layout/MobileShell.tsx) | 129 | utility | Migration pending | -| [ApplyTemplateDialog.tsx](../../web/src/components/task/ApplyTemplateDialog.tsx) | 305 | authoring | Migration pending | -| [PreviewPanel.tsx](../../web/src/components/task/PreviewPanel.tsx) | 71 | utility | Migration pending; migrate with nested consumers | -| [AttachmentsSection.tsx](../../web/src/components/task/AttachmentsSection.tsx) | 172 | confirm | Migration pending | -| [SectionHeader.tsx](../../web/src/components/settings/shared/SectionHeader.tsx) | 65 | confirm | Migration pending | -| [DesktopOnboarding.tsx](../../web/src/components/auth/DesktopOnboarding.tsx) | 711 | authoring | Migration pending | -| [AgentRunTimelinePanel.tsx](../../web/src/components/task/AgentRunTimelinePanel.tsx) | 1373 | form | Migration pending | -| [DeliverablesSection.tsx](../../web/src/components/task/DeliverablesSection.tsx) | 272 | confirm | Migration pending | -| [DrillDownPanel.tsx](../../web/src/components/dashboard/DrillDownPanel.tsx) | 14 | utility | Migration pending | -| [WorkflowSection.tsx](../../web/src/components/task/WorkflowSection.tsx) | 266 | authoring | Migration pending | -| [CommentsSection.tsx](../../web/src/components/task/CommentsSection.tsx) | 190 | confirm | Migration pending | -| [TaskDetailPanel.tsx](../../web/src/components/task/TaskDetailPanel.tsx) | 271 | task | Migration pending | -| [sheet.tsx](../../web/src/components/ui/sheet.tsx) | 147 | utility | Migration pending | -| [alert-dialog.tsx](../../web/src/components/ui/alert-dialog.tsx) | 136 | form | Migration pending | -| [WorkProductsSection.tsx](../../web/src/components/task/WorkProductsSection.tsx) | 504 | authoring | Migration pending | -| [WorkProductsSection.tsx](../../web/src/components/task/WorkProductsSection.tsx) | 551 | authoring | Migration pending | -| [AgentPanel.tsx](../../web/src/components/task/AgentPanel.tsx) | 630 | confirm | Migration pending | -| [AgentPanel.tsx](../../web/src/components/task/AgentPanel.tsx) | 661 | form | Migration pending | -| [dialog.tsx](../../web/src/components/ui/dialog.tsx) | 157 | form | Migration pending | -| [ExportDialog.tsx](../../web/src/components/dashboard/ExportDialog.tsx) | 106 | form | Migration pending | -| [SkillRiskDashboardPanel.tsx](../../web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx) | 339 | form | Migration pending | -| [ToolPoliciesTab.tsx](../../web/src/components/settings/tabs/ToolPoliciesTab.tsx) | 268 | authoring | Migration pending | -| [GeneralTab.tsx](../../web/src/components/settings/tabs/GeneralTab.tsx) | 403 | confirm | Migration pending | -| [TemplateComponents.tsx](../../web/src/components/settings/tabs/TemplateComponents.tsx) | 251 | confirm | Migration pending | -| [SecurityTab.tsx](../../web/src/components/settings/tabs/SecurityTab.tsx) | 173 | confirm | Migration pending | -| [AgentsTab.tsx](../../web/src/components/settings/tabs/AgentsTab.tsx) | 2329 | confirm | Migration pending | -| [MaintenanceTab.tsx](../../web/src/components/settings/tabs/MaintenanceTab.tsx) | 590 | form | Migration pending | +| Consumer | Opening line | Intended variant | Status | +| ------------------------------------------------------------------------------------------------- | -----------: | ---------------- | ----------------------------------------------------- | +| [TemplateEditorDialog.tsx](../../web/src/components/templates/TemplateEditorDialog.tsx) | 188 | authoring | Shared primitive adopted; runtime acceptance pending | +| [TemplatesPage.tsx](../../web/src/components/templates/TemplatesPage.tsx) | 274 | confirm | Shared primitive adopted; runtime acceptance pending | +| [TemplatesPage.tsx](../../web/src/components/templates/TemplatesPage.tsx) | 298 | form | Shared primitive adopted; runtime acceptance pending | +| [SearchDialog.tsx](../../web/src/components/search/SearchDialog.tsx) | 282 | authoring | Migration pending; migrate with nested consumers | +| [AdmissionQueuePanel.tsx](../../web/src/components/digest/AdmissionQueuePanel.tsx) | 315 | form | Migration pending | +| [WorkflowStartDialog.tsx](../../web/src/components/workflows/WorkflowStartDialog.tsx) | 71 | form | Migration pending; migrate with nested consumers | +| [CreateTaskDialog.tsx](../../web/src/components/task/CreateTaskDialog.tsx) | 267 | form | Migration pending; migrate with nested consumers | +| [PolicyManager.tsx](../../web/src/components/policies/PolicyManager.tsx) | 552 | authoring | Shared modal; runtime acceptance pending in #1423 | +| [PolicyManager.tsx](../../web/src/components/policies/PolicyManager.tsx) | 906 | form | Shared modal; runtime acceptance pending in #1423 | +| [TimeTrackingSection.tsx](../../web/src/components/task/TimeTrackingSection.tsx) | 243 | form | Migration pending | +| [BulkActionsBar.tsx](../../web/src/components/board/BulkActionsBar.tsx) | 366 | confirm | Migration pending | +| [ArchiveSuggestionBanner.tsx](../../web/src/components/board/ArchiveSuggestionBanner.tsx) | 92 | confirm | Migration pending | +| [ConflictResolver.tsx](../../web/src/components/task/ConflictResolver.tsx) | 124 | utility | Migration pending | +| [ConflictResolver.tsx](../../web/src/components/task/ConflictResolver.tsx) | 383 | confirm | Migration pending | +| [FilterBar.tsx](../../web/src/components/board/FilterBar.tsx) | 349 | form | Migration pending | +| [FilterBar.tsx](../../web/src/components/board/FilterBar.tsx) | 374 | confirm | Migration pending | +| [ChatPanel.tsx](../../web/src/components/chat/ChatPanel.tsx) | 279 | chat | Migration pending; migrate with nested consumers | +| [ChatPanel.tsx](../../web/src/components/chat/ChatPanel.tsx) | 297 | confirm | Migration pending; migrate with nested consumers | +| [TaskWorkView.tsx](../../web/src/components/task/TaskWorkView.tsx) | 1055 | confirm | Migration pending | +| [ObservationsSection.tsx](../../web/src/components/task/ObservationsSection.tsx) | 118 | confirm | Migration pending | +| [SquadChatPanel.tsx](../../web/src/components/chat/SquadChatPanel.tsx) | 522 | chat | Migration pending; migrate with nested consumers | +| [ReviewPanel.tsx](../../web/src/components/task/ReviewPanel.tsx) | 212 | form | Migration pending | +| [ArtifactPreviewModal.tsx](../../web/src/components/task/ArtifactPreviewModal.tsx) | 112 | authoring | Migration pending | +| [CommandPalette.tsx](../../web/src/components/layout/CommandPalette.tsx) | 293 | form | Migration pending; migrate with nested consumers | +| [SettingsDialog.tsx](../../web/src/components/settings/SettingsDialog.tsx) | 529 | authoring | Shared geometry; browser passed; native pending #1436 | +| [SettingsDialog.tsx](../../web/src/components/settings/SettingsDialog.tsx) | 739 | confirm | Shared geometry; browser passed; native pending #1436 | +| [PRDialog.tsx](../../web/src/components/task/git/PRDialog.tsx) | 36 | authoring | Migration pending | +| [ActivitySidebar.tsx](../../web/src/components/layout/ActivitySidebar.tsx) | 244 | utility | Migration pending | +| [SortableListItem.tsx](../../web/src/components/settings/SortableListItem.tsx) | 171 | confirm | Shared geometry; browser passed; native pending #1436 | +| [WorktreeStatus.tsx](../../web/src/components/task/git/WorktreeStatus.tsx) | 374 | form | Migration pending | +| [WorktreeStatus.tsx](../../web/src/components/task/git/WorktreeStatus.tsx) | 404 | confirm | Migration pending | +| [TaskDetailsTab.tsx](../../web/src/components/task/detail/TaskDetailsTab.tsx) | 254 | confirm | Migration pending | +| [KeyboardShortcutsDialog.tsx](../../web/src/components/layout/KeyboardShortcutsDialog.tsx) | 49 | form | Migration pending | +| [ArchiveSidebar.tsx](../../web/src/components/layout/ArchiveSidebar.tsx) | 240 | utility | Migration pending | +| [MobileShell.tsx](../../web/src/components/layout/MobileShell.tsx) | 129 | utility | Migration pending | +| [ApplyTemplateDialog.tsx](../../web/src/components/task/ApplyTemplateDialog.tsx) | 305 | authoring | Migration pending | +| [PreviewPanel.tsx](../../web/src/components/task/PreviewPanel.tsx) | 71 | utility | Migration pending; migrate with nested consumers | +| [AttachmentsSection.tsx](../../web/src/components/task/AttachmentsSection.tsx) | 172 | confirm | Migration pending | +| [SectionHeader.tsx](../../web/src/components/settings/shared/SectionHeader.tsx) | 66 | confirm | Shared geometry; browser passed; native pending #1436 | +| [DesktopOnboarding.tsx](../../web/src/components/auth/DesktopOnboarding.tsx) | 711 | authoring | Migration pending | +| [AgentRunTimelinePanel.tsx](../../web/src/components/task/AgentRunTimelinePanel.tsx) | 1373 | form | Migration pending | +| [DeliverablesSection.tsx](../../web/src/components/task/DeliverablesSection.tsx) | 272 | confirm | Migration pending | +| [DrillDownPanel.tsx](../../web/src/components/dashboard/DrillDownPanel.tsx) | 14 | utility | Migration pending | +| [WorkflowSection.tsx](../../web/src/components/task/WorkflowSection.tsx) | 266 | authoring | Migration pending | +| [CommentsSection.tsx](../../web/src/components/task/CommentsSection.tsx) | 190 | confirm | Migration pending | +| [TaskDetailPanel.tsx](../../web/src/components/task/TaskDetailPanel.tsx) | 271 | task | Migration pending | +| [sheet.tsx](../../web/src/components/ui/sheet.tsx) | 147 | utility | Migration pending | +| [alert-dialog.tsx](../../web/src/components/ui/alert-dialog.tsx) | 136 | form | Migration pending | +| [WorkProductsSection.tsx](../../web/src/components/task/WorkProductsSection.tsx) | 504 | authoring | Migration pending | +| [WorkProductsSection.tsx](../../web/src/components/task/WorkProductsSection.tsx) | 551 | authoring | Migration pending | +| [AgentPanel.tsx](../../web/src/components/task/AgentPanel.tsx) | 630 | confirm | Migration pending | +| [AgentPanel.tsx](../../web/src/components/task/AgentPanel.tsx) | 661 | form | Migration pending | +| [dialog.tsx](../../web/src/components/ui/dialog.tsx) | 157 | form | Migration pending | +| [ExportDialog.tsx](../../web/src/components/dashboard/ExportDialog.tsx) | 106 | form | Migration pending | +| [SkillRiskDashboardPanel.tsx](../../web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx) | 339 | form | Shared geometry; browser passed; native pending #1436 | +| [ToolPoliciesTab.tsx](../../web/src/components/settings/tabs/ToolPoliciesTab.tsx) | 269 | authoring | Shared geometry; browser passed; native pending #1436 | +| [GeneralTab.tsx](../../web/src/components/settings/tabs/GeneralTab.tsx) | 404 | confirm | Shared geometry; browser passed; native pending #1436 | +| [TemplateComponents.tsx](../../web/src/components/settings/tabs/TemplateComponents.tsx) | 252 | confirm | Shared geometry; browser passed; native pending #1436 | +| [SecurityTab.tsx](../../web/src/components/settings/tabs/SecurityTab.tsx) | 174 | confirm | Shared geometry; browser passed; native pending #1436 | +| [AgentsTab.tsx](../../web/src/components/settings/tabs/AgentsTab.tsx) | 2331 | confirm | Shared geometry; browser passed; native pending #1436 | +| [MaintenanceTab.tsx](../../web/src/components/settings/tabs/MaintenanceTab.tsx) | 590 | form | Shared geometry; browser passed; native pending #1436 | The mobile notification drawer is a deliberate bottom-sheet exception; it still needs matching spacing and keyboard checks. Compatibility wrappers in `ui/dialog.tsx`, `ui/alert-dialog.tsx`, and `ui/sheet.tsx` remain inventoried above where they instantiate roots; their public call sites must not be silently treated as migrated. Docked Workbench chat is not a modal and must keep its non-modal focus model while adopting the same header/composer spacing. diff --git a/e2e/helpers/settings-popout-fixtures.ts b/e2e/helpers/settings-popout-fixtures.ts new file mode 100644 index 00000000..8931d5ca --- /dev/null +++ b/e2e/helpers/settings-popout-fixtures.ts @@ -0,0 +1,132 @@ +import type { Page } from '@playwright/test'; +import { DEFAULT_FEATURE_SETTINGS } from '../../shared/dist/index.js'; +import { unwrapApiData } from './auth'; + +/** Read-only fixtures for every Settings child dialog; mutations are blocked by the caller. */ +export async function installSettingsPopoutFixtures(page: Page) { + const fulfillRead = async (path: string, json: unknown) => { + await page.route(path, (route) => + route.request().method() === 'GET' ? route.fulfill({ json }) : route.fallback() + ); + }; + await fulfillRead('**/api/settings/features', { + ...DEFAULT_FEATURE_SETTINGS, + sharedResources: { + ...DEFAULT_FEATURE_SETTINGS.sharedResources, + enabled: true, + allowedTypes: ['skill'], + }, + }); + await fulfillRead('**/api/tool-policies', [ + { role: 'custom', allowed: ['Read'], denied: [], description: '' }, + ]); + await page.route('**/api/config', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + const config = unwrapApiData>(await (await route.fetch()).json()); + await route.fulfill({ + json: { + ...config, + repos: [ + { + name: 'Fixture repository', + path: '/tmp/settings-popout-fixture', + defaultBranch: 'main', + }, + ], + agents: [ + ...(config.agents as unknown[]), + { type: 'fixture', name: 'Fixture Agent', command: 'fixture', args: [], enabled: false }, + ], + }, + }); + }); + const timestamp = '2026-09-01T00:00:00Z'; + // This POST is a read-only compatibility query, not a launch or Settings mutation. + await page.route('**/api/agents/hosts/preview', (route) => + route.fulfill({ + json: { + generatedAt: timestamp, + request: route.request().postDataJSON(), + previews: [], + decision: { policy: 'disabled', reason: 'Synthetic preview only', excludedHostIds: [] }, + }, + }) + ); + await fulfillRead('**/api/task-types', [ + { + id: 'fixture', + label: 'Fixture Type', + icon: 'code', + color: 'gray', + order: 0, + created: timestamp, + updated: timestamp, + }, + ]); + await fulfillRead('**/api/task-types/fixture/can-delete', { + allowed: true, + referenceCount: 0, + isDefault: false, + }); + await fulfillRead('**/api/templates', [ + { + id: 'fixture', + name: 'Fixture Template', + version: 1, + taskDefaults: {}, + created: timestamp, + updated: timestamp, + }, + ]); + await page.route('**/api/maintenance/summary', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + const summary = unwrapApiData>(await (await route.fetch()).json()); + await route.fulfill({ + json: { + ...summary, + cleanupPreview: { + destructiveActionsEnabled: false, + confirmationRequired: true, + notes: ['Synthetic preview only.'], + items: Array.from({ length: 12 }, (_, index) => ({ + id: `fixture-${index}`, + label: `Archived diagnostic output ${index + 1}`, + category: 'logs', + cleanupEligible: true, + affectedCount: 1, + estimatedBytes: 64, + retainedReason: 'Synthetic fixture', + })), + }, + }, + }); + }); + await fulfillRead('**/api/skills/security/inventory', { + generatedAt: timestamp, + totals: { skills: 1, blocked: 0, warnings: 1, unscanned: 0, exceptions: 0 }, + items: [ + { + skillId: 'fixture', + name: 'Fixture skill', + version: 1, + sourcePath: 'fixture/SKILL.md', + tags: [], + mountedIn: [], + updatedAt: timestamp, + lastScannedAt: timestamp, + scanStatus: 'scanned', + changedFiles: [], + severity: 'medium', + riskScore: 25, + recommendation: 'caution', + installDecision: 'warn', + installReason: 'Synthetic warning', + declaredCapabilities: [], + observedCapabilities: [], + mismatches: [], + findingCount: 1, + highOrCriticalFindingCount: 0, + }, + ], + }); +} diff --git a/e2e/settings-popouts.spec.ts b/e2e/settings-popouts.spec.ts new file mode 100644 index 00000000..9a6c3402 --- /dev/null +++ b/e2e/settings-popouts.spec.ts @@ -0,0 +1,157 @@ +import { expect, test, type Locator } from '@playwright/test'; +import { bypassAuth, cleanupRoutes } from './helpers/auth'; +import { installSettingsPopoutFixtures } from './helpers/settings-popout-fixtures'; + +test.afterEach(async ({ page }) => cleanupRoutes(page)); + +for (const theme of ['light', 'dark']) { + test(`Settings nested popouts preserve geometry and focus in ${theme}`, async ({ page }) => { + test.setTimeout(60_000); + await bypassAuth(page); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.addInitScript((theme) => { + localStorage.setItem('veritas-kanban-theme', theme); + Object.defineProperty(window, 'veritasDesktop', { + configurable: true, + value: { onMenuCommand: () => () => undefined }, + }); + }, theme); + let mutations = 0; + await page.route('**/api/**', async (route) => { + if (!['GET', 'HEAD'].includes(route.request().method())) { + mutations++; + await route.abort(); + } else { + await route.fallback(); + } + }); + await installSettingsPopoutFixtures(page); + await page.goto('/'); + const opener = page.getByRole('button', { name: 'Settings', exact: true }); + const settings = page.locator('.settings-dialog-content'); + const settingsRoot = page.locator('[data-overlay-variant]').filter({ has: settings }); + + for (const viewport of [ + { width: 1180, height: 760, font: 16 }, + { width: 900, height: 480, font: 20 }, + ]) { + await page.setViewportSize(viewport); + await page.evaluate((font) => { + document.documentElement.style.fontSize = `${font}px`; + }, viewport.font); + await opener.click(); + await expect(settings).toBeVisible(); + await expect(page.locator('html')).toHaveAttribute('data-mantine-color-scheme', theme); + await expect(settingsRoot).toHaveAttribute('data-overlay-variant', 'authoring'); + const inBounds = async (target: Locator) => { + const box = await target.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.y).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width + 1); + expect(box!.y + box!.height).toBeLessThanOrEqual(viewport.height + 1); + expect(await target.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true); + }; + await inBounds(settings); + await expect(settings.locator('[data-settings-content-scroll]')).toHaveCSS( + 'padding', + `${viewport.font}px` + ); + + const child = async ( + trigger: Locator, + title: string, + first: 'Cancel' | 'Role Name' | 'Allowed Tools' | 'Owner' | 'Confirmation' + ) => { + await trigger.click(); + const dialog = page.getByRole('dialog', { name: title, exact: true }); + await expect(dialog).toBeVisible(); + await expect(settingsRoot).toHaveAttribute('inert', ''); + await expect( + first === 'Cancel' + ? dialog.getByRole('button', { name: first, exact: true }) + : dialog.getByRole('textbox', { name: first, exact: true }) + ).toBeFocused(); + await inBounds(dialog); + await inBounds(dialog.locator('.vk-overlay-footer')); + await page.keyboard.press('Tab'); + expect(await dialog.evaluate((el) => el.contains(document.activeElement))).toBe(true); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await expect(settingsRoot).not.toHaveAttribute('inert'); + await expect(trigger).toBeFocused(); + }; + + await child( + settings.getByRole('button', { name: 'Reset All', exact: true }), + 'Reset all settings?', + 'Cancel' + ); + await settings.getByRole('tab', { name: 'General', exact: true }).click(); + await child( + settings.getByRole('button', { name: 'Remove Fixture repository', exact: true }), + 'Remove repository?', + 'Cancel' + ); + await settings.getByRole('tab', { name: 'Tasks', exact: true }).click(); + await child( + settings.getByRole('button', { name: 'Reset', exact: true }).first(), + 'Reset to defaults?', + 'Cancel' + ); + await settings.getByRole('tab', { name: 'Tool Policies', exact: true }).click(); + await child( + settings.getByRole('button', { name: 'New Policy', exact: true }), + 'Create Tool Policy', + 'Role Name' + ); + await child( + settings.getByRole('button', { name: 'Edit custom', exact: true }), + 'Edit Policy: custom', + 'Allowed Tools' + ); + await settings.getByRole('tab', { name: 'Security', exact: true }).click(); + await child( + settings.getByRole('button', { name: 'Reset All Security', exact: true }), + 'Reset all security settings?', + 'Cancel' + ); + await settings.getByRole('tab', { name: 'Agents', exact: true }).click(); + await child( + settings.getByRole('button', { name: 'Remove Fixture Agent', exact: true }), + 'Remove agent?', + 'Cancel' + ); + await settings.getByRole('tab', { name: 'Manage', exact: true }).click(); + await child( + settings.getByRole('button', { name: 'Delete Fixture Type', exact: true }), + 'Delete Item?', + 'Cancel' + ); + await child( + settings.getByRole('button', { name: 'Delete Fixture Template', exact: true }), + 'Delete template?', + 'Cancel' + ); + await settings.getByRole('tab', { name: 'Maintenance', exact: true }).click(); + await child( + settings.getByRole('button', { name: 'Review Cleanup', exact: true }), + 'Review cleanup', + 'Confirmation' + ); + await settings.getByRole('tab', { name: 'Shared Resources', exact: true }).click(); + await child( + settings.getByRole('button', { name: 'Exception', exact: true }), + 'Exception for Fixture skill', + 'Owner' + ); + await page.keyboard.press('Escape'); + await expect(settings).toBeHidden(); + await expect(opener).toBeFocused(); + expect( + await page.evaluate(() => document.documentElement.scrollHeight <= innerHeight + 1) + ).toBe(true); + } + expect(mutations).toBe(0); + }); +} diff --git a/web/src/__tests__/settings-dialog-mantine.test.tsx b/web/src/__tests__/settings-dialog-mantine.test.tsx index ee60f3aa..8d387e05 100644 --- a/web/src/__tests__/settings-dialog-mantine.test.tsx +++ b/web/src/__tests__/settings-dialog-mantine.test.tsx @@ -116,7 +116,7 @@ describe('SettingsDialog Mantine shell', () => { expect(screen.getByRole('tab', { name: 'General' }).getAttribute('aria-selected')).toBe('true'); expect(screen.getAllByLabelText('Select settings section').length).toBeGreaterThanOrEqual(1); expect(baseElement.querySelector('.mantine-Button-root')).toBeDefined(); - expect(baseElement.querySelector('.mantine-ScrollArea-root')).toBeDefined(); + expect(baseElement.querySelector('[data-settings-content-scroll]')).not.toBeNull(); expect(baseElement.querySelector('.mantine-Select-root')).toBeDefined(); const closeButton = screen.getByRole('button', { name: 'Close settings' }); diff --git a/web/src/components/settings/SettingsDialog.tsx b/web/src/components/settings/SettingsDialog.tsx index 6df7b8e9..c79623e8 100644 --- a/web/src/components/settings/SettingsDialog.tsx +++ b/web/src/components/settings/SettingsDialog.tsx @@ -1,6 +1,7 @@ +import { UiModal as Modal, OverlayFooter } from '@/components/ui/UiOverlay'; import { UiPill, UiAction, UiIconAction } from '@/components/ui/UiVocabulary'; import { useState, useRef, useCallback, lazy, Suspense, useEffect, useMemo } from 'react'; -import { Group, Menu, Modal, ScrollArea, Select, Skeleton, Stack, Text } from '@mantine/core'; +import { Group, Menu, Select, Skeleton, Stack, Text } from '@mantine/core'; import { useFeatureSettings, useDebouncedFeatureUpdate } from '@/hooks/useFeatureSettings'; import { useIdentity } from '@/hooks/useIdentity'; import { useToast } from '@/hooks/useToast'; @@ -290,18 +291,9 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo const { toast } = useToast(); const dialogContentRef = useRef(null); const contentAreaRef = useRef(null); - const firstTabButtonRef = useRef(null); const keyboardTabChange = useRef(false); const [resetAllOpen, setResetAllOpen] = useState(false); - // Focus first tab when dialog opens - useEffect(() => { - if (open && firstTabButtonRef.current) { - // Small delay to ensure dialog is fully rendered - setTimeout(() => firstTabButtonRef.current?.focus(), 100); - } - }, [open]); - // Focus content area when switching tabs useEffect(() => { if (contentAreaRef.current) { @@ -426,53 +418,6 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo [activeTab, canUseTab] ); - const handleDialogKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key !== 'Tab') return; - - const container = dialogContentRef.current; - if (!container) return; - - const focusable = Array.from( - container.querySelectorAll( - [ - '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 ( }> @@ -582,6 +527,8 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo return ( onOpenChange(false)} title={ @@ -592,29 +539,18 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo {isBoardOnly && Board Only} } - size={1040} - padding={0} centered trapFocus returnFocus closeButtonProps={{ 'aria-label': 'Close settings' }} classNames={{ - content: 'settings-dialog-content', - header: 'settings-dialog-header border-b border-border', + content: 'settings-dialog-content h-dvh', + header: 'settings-dialog-header', body: 'settings-dialog-body', }} - styles={{ - content: { height: '85vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' }, - header: { minHeight: '3rem', padding: '0.5rem 0.75rem 0.5rem 1rem' }, - body: { flex: 1, minHeight: 0, padding: 0 }, - }} > -
+
{/* Sidebar Tabs — hidden on narrow screens, shown as dropdown instead */}
@@ -658,7 +594,6 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo className="settings-nav-action" key={tab.id} id={`tab-${tab.id}`} - ref={tab.id === 'general' ? firstTabButtonRef : undefined} type="button" role="tab" aria-label={tab.label} @@ -786,41 +721,43 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
- +
{renderTab()}
- +
setResetAllOpen(false)} title="Reset all settings?" centered > - + This will reset ALL feature settings across every section back to their default values. This cannot be undone. - - setResetAllOpen(false)}> - Cancel - - - Reset Everything - - + + setResetAllOpen(false)}> + Cancel + + + Reset Everything + + ); diff --git a/web/src/components/settings/SortableListItem.tsx b/web/src/components/settings/SortableListItem.tsx index b760c762..e676d8c3 100644 --- a/web/src/components/settings/SortableListItem.tsx +++ b/web/src/components/settings/SortableListItem.tsx @@ -1,8 +1,9 @@ +import { UiModal as Modal, OverlayFooter } from '@/components/ui/UiOverlay'; import { UiIconAction, UiAction } from '@/components/ui/UiVocabulary'; import { SettingsGroup } from '@/components/settings/shared/SettingsLayout'; import { useState, memo } from 'react'; import type { ManagedListItem } from '@veritas-kanban/shared'; -import { Group, Modal, Stack, Text, TextInput } from '@mantine/core'; +import { Stack, Text, TextInput } from '@mantine/core'; import { Trash2, GripVertical, ChevronUp, ChevronDown } from 'lucide-react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; @@ -168,12 +169,14 @@ export const SortableListItem = memo(function SortableListItem setDeleteDialogOpen(false)} title={deleteInfo && !deleteInfo.allowed ? 'Cannot Delete' : 'Delete Item?'} centered > - + {deleteInfo && deleteInfo.referenceCount > 0 && !deleteInfo.allowed ? ( <> @@ -187,17 +190,17 @@ export const SortableListItem = memo(function SortableListItem )} - - setDeleteDialogOpen(false)}> - Cancel - - {(!deleteInfo || deleteInfo.allowed) && ( - - Delete - - )} - + + setDeleteDialogOpen(false)}> + Cancel + + {(!deleteInfo || deleteInfo.allowed) && ( + + Delete + + )} + ); diff --git a/web/src/components/settings/shared/SectionHeader.tsx b/web/src/components/settings/shared/SectionHeader.tsx index 40a27b60..fac1f3e2 100644 --- a/web/src/components/settings/shared/SectionHeader.tsx +++ b/web/src/components/settings/shared/SectionHeader.tsx @@ -1,7 +1,8 @@ +import { UiModal as Modal, OverlayFooter } from '@/components/ui/UiOverlay'; import { UiAction, UiHeading } from '@/components/ui/UiVocabulary'; import { useState } from 'react'; import type { ReactNode } from 'react'; -import { Group, Modal, Stack, Text } from '@mantine/core'; +import { Group, Stack, Text } from '@mantine/core'; import { RotateCcw } from 'lucide-react'; export function SectionHeader({ @@ -63,24 +64,26 @@ export function SectionHeader({ Reset setResetOpen(false)} title="Reset to defaults?" centered > - + This will reset all {title.toLowerCase()} settings to their default values. - - setResetOpen(false)}> - Cancel - - - Reset - - + + setResetOpen(false)}> + Cancel + + + Reset + + )} diff --git a/web/src/components/settings/tabs/AgentsTab.tsx b/web/src/components/settings/tabs/AgentsTab.tsx index d2b48dbe..dc34241b 100644 --- a/web/src/components/settings/tabs/AgentsTab.tsx +++ b/web/src/components/settings/tabs/AgentsTab.tsx @@ -1,3 +1,4 @@ +import { UiModal as Modal, OverlayFooter } from '@/components/ui/UiOverlay'; import { UiHeading, UiAction, @@ -8,7 +9,7 @@ import { import { SettingsGroup, SettingsNotice } from '@/components/settings/shared/SettingsLayout'; import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Modal, NumberInput, Select, Switch, Text, Textarea, TextInput } from '@mantine/core'; +import { NumberInput, Select, Switch, Text, Textarea, TextInput } from '@mantine/core'; import { useAgentProfiles, useCodexHealth, @@ -2328,17 +2329,21 @@ function AgentItem({ {!isDefault && ( setConfirmRemoveOpen(false)} title="Remove agent?" centered > - - This will remove “{agent.name}” ({agent.type}) from your agent - configuration. - -
- setConfirmRemoveOpen(false)}> +
+ + This will remove “{agent.name}” ({agent.type}) from your agent + configuration. + +
+ + setConfirmRemoveOpen(false)}> Cancel Remove -
+
)} diff --git a/web/src/components/settings/tabs/GeneralTab.tsx b/web/src/components/settings/tabs/GeneralTab.tsx index 8ff9c3c0..ffee27e2 100644 --- a/web/src/components/settings/tabs/GeneralTab.tsx +++ b/web/src/components/settings/tabs/GeneralTab.tsx @@ -1,7 +1,8 @@ +import { UiModal as Modal, OverlayFooter } from '@/components/ui/UiOverlay'; import { UiPill, UiAction, UiIconAction } from '@/components/ui/UiVocabulary'; import { SettingsGroup } from '@/components/settings/shared/SettingsLayout'; import { useState } from 'react'; -import { Group, Modal, Select, SimpleGrid, Stack, Switch, Text, TextInput } from '@mantine/core'; +import { Group, Select, SimpleGrid, Stack, Switch, Text, TextInput } from '@mantine/core'; import { useConfig, useAddRepo, @@ -401,24 +402,26 @@ function RepoItem({ repo }: { repo: RepoConfig }) { setRemoveOpen(false)} title="Remove repository?" centered > - + This will remove "{repo.name}" from your configuration. - - setRemoveOpen(false)}> - Cancel - - - Remove - - + + setRemoveOpen(false)}> + Cancel + + + Remove + +
diff --git a/web/src/components/settings/tabs/MaintenanceTab.tsx b/web/src/components/settings/tabs/MaintenanceTab.tsx index 5f0065b7..b4c61152 100644 --- a/web/src/components/settings/tabs/MaintenanceTab.tsx +++ b/web/src/components/settings/tabs/MaintenanceTab.tsx @@ -1,3 +1,4 @@ +import { UiModal as Modal, OverlayFooter } from '@/components/ui/UiOverlay'; import { UiPill, semanticToneForLegacyColor, UiAction } from '@/components/ui/UiVocabulary'; import { SettingsGroup, SettingsNotice } from '@/components/settings/shared/SettingsLayout'; import { useEffect, useMemo, useState } from 'react'; @@ -7,7 +8,6 @@ import { Code, Group, Loader, - Modal, NumberInput, Progress, Select, @@ -588,31 +588,34 @@ export function MaintenanceTab() { setCleanupOpen(false)} title="Review cleanup" centered > - + {summary.cleanupPreview.notes.join(' ')} setCleanupConfirm(event.currentTarget.value)} placeholder="Type DELETE" /> - - setCleanupOpen(false)}> - Close - - - Delete Previewed Items - - + + setCleanupOpen(false)}> + Close + + + Delete Previewed Items + + ); diff --git a/web/src/components/settings/tabs/SecurityTab.tsx b/web/src/components/settings/tabs/SecurityTab.tsx index a7ac9bea..25f352e1 100644 --- a/web/src/components/settings/tabs/SecurityTab.tsx +++ b/web/src/components/settings/tabs/SecurityTab.tsx @@ -1,8 +1,9 @@ +import { UiModal as Modal, OverlayFooter } from '@/components/ui/UiOverlay'; import { UiAction } from '@/components/ui/UiVocabulary'; import { SettingsGroup } from '@/components/settings/shared/SettingsLayout'; import { useState } from 'react'; import { useAuth } from '@/hooks/useAuth'; -import { Group, Modal, PasswordInput, Stack, Text } from '@mantine/core'; +import { PasswordInput, Stack, Text } from '@mantine/core'; import { Eye, EyeOff, Check } from 'lucide-react'; import { useToast } from '@/hooks/useToast'; import { authApi } from '@/lib/api/auth'; @@ -171,43 +172,45 @@ export function SecurityTab() { Reset All Security setResetOpen(false)} title="Reset all security settings?" centered > - + This action cannot be undone. Your password and recovery key will be deleted. You'll need to set up a new password on the next page load. - - setResetOpen(false)}> - Cancel - - { - // Call the reset endpoint - try { - await authApi.reset(); - window.location.reload(); - } catch (err) { - console.error('[Security] Auth reset failed:', err); - toast({ - title: 'Reset failed', - description: 'Please use the CLI command instead.', - duration: 5000, - }); - } finally { - setResetOpen(false); - } - }} - > - Reset Everything - - + + setResetOpen(false)}> + Cancel + + { + // Call the reset endpoint + try { + await authApi.reset(); + window.location.reload(); + } catch (err) { + console.error('[Security] Auth reset failed:', err); + toast({ + title: 'Reset failed', + description: 'Please use the CLI command instead.', + duration: 5000, + }); + } finally { + setResetOpen(false); + } + }} + > + Reset Everything + + diff --git a/web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx b/web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx index 2f25032a..f7eeb5f7 100644 --- a/web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx +++ b/web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx @@ -1,3 +1,4 @@ +import { UiModal as Modal, OverlayFooter } from '@/components/ui/UiOverlay'; import { UiPill, semanticToneForLegacyColor, @@ -9,7 +10,6 @@ import { useMemo, useState } from 'react'; import { Group, Loader, - Modal, SimpleGrid, Stack, Table, @@ -337,12 +337,15 @@ export function SkillRiskDashboardPanel() { setExceptionSkill(null)} title={exceptionSkill ? `Exception for ${exceptionSkill.name}` : 'Skill exception'} > - + setExceptionOwner(event.currentTarget.value)} @@ -361,19 +364,19 @@ export function SkillRiskDashboardPanel() { onChange={(event) => setExceptionReason(event.currentTarget.value)} placeholder="Why this skill is allowed temporarily" /> - - setExceptionSkill(null)}> - Cancel - - - Save Exception - - + + setExceptionSkill(null)}> + Cancel + + + Save Exception + + ); diff --git a/web/src/components/settings/tabs/TemplateComponents.tsx b/web/src/components/settings/tabs/TemplateComponents.tsx index d93992a3..50b704f5 100644 --- a/web/src/components/settings/tabs/TemplateComponents.tsx +++ b/web/src/components/settings/tabs/TemplateComponents.tsx @@ -1,7 +1,8 @@ +import { UiModal as Modal, OverlayFooter } from '@/components/ui/UiOverlay'; import { UiAction, UiPill, UiIconAction } from '@/components/ui/UiVocabulary'; import { SettingsGroup } from '@/components/settings/shared/SettingsLayout'; import { useState } from 'react'; -import { Group, Modal, Select, Stack, Text, Textarea, TextInput } from '@mantine/core'; +import { Group, Select, Stack, Text, Textarea, TextInput } from '@mantine/core'; import { type TaskTemplate, useCreateTemplate, @@ -249,30 +250,32 @@ export function TemplateItem({ template }: { template: TaskTemplate }) { setDeleteOpen(false)} title="Delete template?" centered > - + This will delete "{template.name}". - - setDeleteOpen(false)}> - Cancel - - { - deleteTemplate.mutate(template.id); - setDeleteOpen(false); - }} - > - Delete - - + + setDeleteOpen(false)}> + Cancel + + { + deleteTemplate.mutate(template.id); + setDeleteOpen(false); + }} + > + Delete + + ); diff --git a/web/src/components/settings/tabs/ToolPoliciesTab.tsx b/web/src/components/settings/tabs/ToolPoliciesTab.tsx index 9e073b29..2b011c60 100644 --- a/web/src/components/settings/tabs/ToolPoliciesTab.tsx +++ b/web/src/components/settings/tabs/ToolPoliciesTab.tsx @@ -1,3 +1,4 @@ +import { UiModal as Modal, OverlayFooter } from '@/components/ui/UiOverlay'; import { UiAction, UiPill, UiIconAction } from '@/components/ui/UiVocabulary'; import { SettingsNotice, SettingsGroup } from '@/components/settings/shared/SettingsLayout'; /** @@ -8,7 +9,7 @@ import { SettingsNotice, SettingsGroup } from '@/components/settings/shared/Sett */ import { useState, useEffect, useCallback } from 'react'; -import { Group, Modal, Stack, Text, TextInput, Textarea } from '@mantine/core'; +import { Stack, Text, TextInput, Textarea } from '@mantine/core'; import { apiFetch } from '@/lib/api/helpers'; import { useToast } from '@/hooks/useToast'; import { Edit, Info, Plus, Trash2 } from 'lucide-react'; @@ -266,20 +267,21 @@ export function ToolPoliciesTab() { setEditDialogOpen(false)} title={isNew ? 'Create Tool Policy' : `Edit Policy: ${formRole}`} - size="lg" - radius="md" centered > - + Define tool access restrictions for an agent role. Denied tools take precedence over allowed tools. - - - setEditDialogOpen(false)}> - Cancel - - - {isNew ? 'Create' : 'Save Changes'} - - + + setEditDialogOpen(false)}> + Cancel + + + {isNew ? 'Create' : 'Save Changes'} + + ); From be7dd685996c306ce0c6dd6984fe9d884bd33159 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:59:01 -0500 Subject: [PATCH 2/7] fix: focus the visible cleanup review action --- docs/audits/SETTINGS-POPOUTS-1436.md | 2 +- e2e/settings-popouts.spec.ts | 13 +++++++------ web/src/components/settings/tabs/MaintenanceTab.tsx | 3 +-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/audits/SETTINGS-POPOUTS-1436.md b/docs/audits/SETTINGS-POPOUTS-1436.md index 7a794719..f6ca5f01 100644 --- a/docs/audits/SETTINGS-POPOUTS-1436.md +++ b/docs/audits/SETTINGS-POPOUTS-1436.md @@ -4,7 +4,7 @@ Issue #1436; parent #1383. The Settings root adopts the shared authoring modal, shared header/close geometry, and one primary content scroller with a 1rem inset. Its navigation retains a separate bounded scroller. The previous 85vh shell, local header dimensions, delayed autofocus, and extra hand-written Tab trap are removed. Shared focus trapping remains active. -All ten nested Settings dialogs migrate together: global reset, section reset, managed-list deletion, repository removal, security reset, agent removal, template deletion, tool-policy editing, cleanup preview, and skill exception. Each uses a shared variant with a separately scrolling body and fixed footer. Confirmations initially focus Cancel; forms focus their first enabled field. Existing permissions and callbacks are unchanged. +All ten nested Settings dialogs migrate together: global reset, section reset, managed-list deletion, repository removal, security reset, agent removal, template deletion, tool-policy editing, cleanup preview, and skill exception. Each uses a shared variant with a separately scrolling body and fixed footer. Confirmations initially focus Cancel; cleanup review focuses its visible Close action; other forms focus their first enabled field. Existing permissions and callbacks are unchanged. The shared build, web typecheck, changed-source lint, and 40 focused Settings unit checks pass. Specification and standards source reviews found no outstanding implementation findings. The all-child browser check passes both themes at 1180×760 with 16px text and 900×480 with 20px text: eleven openings cover all ten nested dialog implementations, including policy creation and editing. Checks cover root containment, exact 1rem content inset, nested parent inertness, initial focus, footer bounds, Tab containment, Escape, exact opener restoration, and blocked mutations. The browser fixture identifies the desktop shell but is not a packaged-app test. diff --git a/e2e/settings-popouts.spec.ts b/e2e/settings-popouts.spec.ts index 9a6c3402..ad74a0e5 100644 --- a/e2e/settings-popouts.spec.ts +++ b/e2e/settings-popouts.spec.ts @@ -61,17 +61,18 @@ for (const theme of ['light', 'dark']) { const child = async ( trigger: Locator, title: string, - first: 'Cancel' | 'Role Name' | 'Allowed Tools' | 'Owner' | 'Confirmation' + first: 'Cancel' | 'Close' | 'Role Name' | 'Allowed Tools' | 'Owner' ) => { await trigger.click(); const dialog = page.getByRole('dialog', { name: title, exact: true }); await expect(dialog).toBeVisible(); await expect(settingsRoot).toHaveAttribute('inert', ''); - await expect( - first === 'Cancel' + const initial = + first === 'Cancel' || first === 'Close' ? dialog.getByRole('button', { name: first, exact: true }) - : dialog.getByRole('textbox', { name: first, exact: true }) - ).toBeFocused(); + : dialog.getByRole('textbox', { name: first, exact: true }); + await expect(initial).toBeFocused(); + await expect(initial).toBeInViewport({ ratio: 1 }); await inBounds(dialog); await inBounds(dialog.locator('.vk-overlay-footer')); await page.keyboard.press('Tab'); @@ -137,7 +138,7 @@ for (const theme of ['light', 'dark']) { await child( settings.getByRole('button', { name: 'Review Cleanup', exact: true }), 'Review cleanup', - 'Confirmation' + 'Close' ); await settings.getByRole('tab', { name: 'Shared Resources', exact: true }).click(); await child( diff --git a/web/src/components/settings/tabs/MaintenanceTab.tsx b/web/src/components/settings/tabs/MaintenanceTab.tsx index b4c61152..bf1c98bd 100644 --- a/web/src/components/settings/tabs/MaintenanceTab.tsx +++ b/web/src/components/settings/tabs/MaintenanceTab.tsx @@ -601,7 +601,6 @@ export function MaintenanceTab() { {summary.cleanupPreview.notes.join(' ')} setCleanupConfirm(event.currentTarget.value)} @@ -609,7 +608,7 @@ export function MaintenanceTab() { /> - setCleanupOpen(false)}> + setCleanupOpen(false)}> Close From ffd91a46b2b46c0488199da3d9d9ea9c9c8a8935 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:02:05 -0500 Subject: [PATCH 3/7] docs: record packaged Settings popout acceptance --- docs/audits/SETTINGS-POPOUTS-1436.md | 12 ++- docs/design/OVERLAY-CONTRACT.md | 126 +++++++++++++-------------- 2 files changed, 74 insertions(+), 64 deletions(-) diff --git a/docs/audits/SETTINGS-POPOUTS-1436.md b/docs/audits/SETTINGS-POPOUTS-1436.md index f6ca5f01..d87fc40d 100644 --- a/docs/audits/SETTINGS-POPOUTS-1436.md +++ b/docs/audits/SETTINGS-POPOUTS-1436.md @@ -12,4 +12,14 @@ Initial browser attempts corrected test setup errors: the shared variant/inert a Read-only host-preview POSTs have an explicit synthetic response fixture. All other non-read API requests remain blocked and the test observes zero mutations; no reset, deletion, save, cleanup, or launch is performed. -Rebuilt packaged macOS verification, installed-app replacement, final documentation images/GIFs, and release remain unfinished. +## Packaged macOS acceptance + +The unsigned arm64 Electron candidate at integration commit `4f747e6f14c3e90549644dabf46aff29b658620f` was rebuilt with `pnpm desktop:package:mac:dir`. Its packaged `app.asar` SHA-256 is `57e855b0ab4d3a107ada9d9fe5dcdb6aea31e69c6cb73453cd27e195cf4b9aaf`. The application reports version 6.1.6 and `app.isPackaged` was asserted; this is not a signed release. + +All eleven child openings passed in both themes at 1700×900 with 16px text and the native minimum 1180×760 with 20px text. Checks prove root and child containment, 1rem body inset, parent inertness, fully visible initial focus, footer bounds, Tab containment, Escape, exact opener restoration, and no document overflow. The five Tasks fields identified in the alignment report also share their left edge, width, and height within 2px, including unit-bearing fields and Default Priority. All mutations were blocked after isolated-profile onboarding, with zero unexpected write requests. + +Screenshot review of the first candidate exposed an offscreen cleanup confirmation focus target despite a passing focus assertion. Initial focus was moved to the fixed-footer Close action. Both browser cases and four Maintenance unit tests passed again; both review axes found no outstanding findings. The rebuilt final native run passed the stricter fully-visible-focus assertion and its cleanup capture shows the focus ring on Close. + +Diagnostic screenshots and the local native harness are under `/private/tmp/vk-native-ui-1389.8cJNtK/`; run with `NATIVE_SCOPE=settings-popouts pnpm --filter @veritas-kanban/server exec tsx /tmp/vk-native-ui-1389.8cJNtK/verify.mjs` from the integration worktree. The owned application was closed after verification. These captures are not final maintained documentation media. + +Source CI, installed-app replacement, final documentation images/GIFs, the remaining whole-app audit, and release remain separate unfinished gates. diff --git a/docs/design/OVERLAY-CONTRACT.md b/docs/design/OVERLAY-CONTRACT.md index efa76627..bca15f18 100644 --- a/docs/design/OVERLAY-CONTRACT.md +++ b/docs/design/OVERLAY-CONTRACT.md @@ -23,69 +23,69 @@ The shared provider registers only open overlays, removes unmounted entries, and Line numbers identify the audited opening and may move during migration. A shared primitive alone does not prove the feature's inner scroll, spacing, or keyboard behavior. Each row remains subject to rendered acceptance. -| Consumer | Opening line | Intended variant | Status | -| ------------------------------------------------------------------------------------------------- | -----------: | ---------------- | ----------------------------------------------------- | -| [TemplateEditorDialog.tsx](../../web/src/components/templates/TemplateEditorDialog.tsx) | 188 | authoring | Shared primitive adopted; runtime acceptance pending | -| [TemplatesPage.tsx](../../web/src/components/templates/TemplatesPage.tsx) | 274 | confirm | Shared primitive adopted; runtime acceptance pending | -| [TemplatesPage.tsx](../../web/src/components/templates/TemplatesPage.tsx) | 298 | form | Shared primitive adopted; runtime acceptance pending | -| [SearchDialog.tsx](../../web/src/components/search/SearchDialog.tsx) | 282 | authoring | Migration pending; migrate with nested consumers | -| [AdmissionQueuePanel.tsx](../../web/src/components/digest/AdmissionQueuePanel.tsx) | 315 | form | Migration pending | -| [WorkflowStartDialog.tsx](../../web/src/components/workflows/WorkflowStartDialog.tsx) | 71 | form | Migration pending; migrate with nested consumers | -| [CreateTaskDialog.tsx](../../web/src/components/task/CreateTaskDialog.tsx) | 267 | form | Migration pending; migrate with nested consumers | -| [PolicyManager.tsx](../../web/src/components/policies/PolicyManager.tsx) | 552 | authoring | Shared modal; runtime acceptance pending in #1423 | -| [PolicyManager.tsx](../../web/src/components/policies/PolicyManager.tsx) | 906 | form | Shared modal; runtime acceptance pending in #1423 | -| [TimeTrackingSection.tsx](../../web/src/components/task/TimeTrackingSection.tsx) | 243 | form | Migration pending | -| [BulkActionsBar.tsx](../../web/src/components/board/BulkActionsBar.tsx) | 366 | confirm | Migration pending | -| [ArchiveSuggestionBanner.tsx](../../web/src/components/board/ArchiveSuggestionBanner.tsx) | 92 | confirm | Migration pending | -| [ConflictResolver.tsx](../../web/src/components/task/ConflictResolver.tsx) | 124 | utility | Migration pending | -| [ConflictResolver.tsx](../../web/src/components/task/ConflictResolver.tsx) | 383 | confirm | Migration pending | -| [FilterBar.tsx](../../web/src/components/board/FilterBar.tsx) | 349 | form | Migration pending | -| [FilterBar.tsx](../../web/src/components/board/FilterBar.tsx) | 374 | confirm | Migration pending | -| [ChatPanel.tsx](../../web/src/components/chat/ChatPanel.tsx) | 279 | chat | Migration pending; migrate with nested consumers | -| [ChatPanel.tsx](../../web/src/components/chat/ChatPanel.tsx) | 297 | confirm | Migration pending; migrate with nested consumers | -| [TaskWorkView.tsx](../../web/src/components/task/TaskWorkView.tsx) | 1055 | confirm | Migration pending | -| [ObservationsSection.tsx](../../web/src/components/task/ObservationsSection.tsx) | 118 | confirm | Migration pending | -| [SquadChatPanel.tsx](../../web/src/components/chat/SquadChatPanel.tsx) | 522 | chat | Migration pending; migrate with nested consumers | -| [ReviewPanel.tsx](../../web/src/components/task/ReviewPanel.tsx) | 212 | form | Migration pending | -| [ArtifactPreviewModal.tsx](../../web/src/components/task/ArtifactPreviewModal.tsx) | 112 | authoring | Migration pending | -| [CommandPalette.tsx](../../web/src/components/layout/CommandPalette.tsx) | 293 | form | Migration pending; migrate with nested consumers | -| [SettingsDialog.tsx](../../web/src/components/settings/SettingsDialog.tsx) | 529 | authoring | Shared geometry; browser passed; native pending #1436 | -| [SettingsDialog.tsx](../../web/src/components/settings/SettingsDialog.tsx) | 739 | confirm | Shared geometry; browser passed; native pending #1436 | -| [PRDialog.tsx](../../web/src/components/task/git/PRDialog.tsx) | 36 | authoring | Migration pending | -| [ActivitySidebar.tsx](../../web/src/components/layout/ActivitySidebar.tsx) | 244 | utility | Migration pending | -| [SortableListItem.tsx](../../web/src/components/settings/SortableListItem.tsx) | 171 | confirm | Shared geometry; browser passed; native pending #1436 | -| [WorktreeStatus.tsx](../../web/src/components/task/git/WorktreeStatus.tsx) | 374 | form | Migration pending | -| [WorktreeStatus.tsx](../../web/src/components/task/git/WorktreeStatus.tsx) | 404 | confirm | Migration pending | -| [TaskDetailsTab.tsx](../../web/src/components/task/detail/TaskDetailsTab.tsx) | 254 | confirm | Migration pending | -| [KeyboardShortcutsDialog.tsx](../../web/src/components/layout/KeyboardShortcutsDialog.tsx) | 49 | form | Migration pending | -| [ArchiveSidebar.tsx](../../web/src/components/layout/ArchiveSidebar.tsx) | 240 | utility | Migration pending | -| [MobileShell.tsx](../../web/src/components/layout/MobileShell.tsx) | 129 | utility | Migration pending | -| [ApplyTemplateDialog.tsx](../../web/src/components/task/ApplyTemplateDialog.tsx) | 305 | authoring | Migration pending | -| [PreviewPanel.tsx](../../web/src/components/task/PreviewPanel.tsx) | 71 | utility | Migration pending; migrate with nested consumers | -| [AttachmentsSection.tsx](../../web/src/components/task/AttachmentsSection.tsx) | 172 | confirm | Migration pending | -| [SectionHeader.tsx](../../web/src/components/settings/shared/SectionHeader.tsx) | 66 | confirm | Shared geometry; browser passed; native pending #1436 | -| [DesktopOnboarding.tsx](../../web/src/components/auth/DesktopOnboarding.tsx) | 711 | authoring | Migration pending | -| [AgentRunTimelinePanel.tsx](../../web/src/components/task/AgentRunTimelinePanel.tsx) | 1373 | form | Migration pending | -| [DeliverablesSection.tsx](../../web/src/components/task/DeliverablesSection.tsx) | 272 | confirm | Migration pending | -| [DrillDownPanel.tsx](../../web/src/components/dashboard/DrillDownPanel.tsx) | 14 | utility | Migration pending | -| [WorkflowSection.tsx](../../web/src/components/task/WorkflowSection.tsx) | 266 | authoring | Migration pending | -| [CommentsSection.tsx](../../web/src/components/task/CommentsSection.tsx) | 190 | confirm | Migration pending | -| [TaskDetailPanel.tsx](../../web/src/components/task/TaskDetailPanel.tsx) | 271 | task | Migration pending | -| [sheet.tsx](../../web/src/components/ui/sheet.tsx) | 147 | utility | Migration pending | -| [alert-dialog.tsx](../../web/src/components/ui/alert-dialog.tsx) | 136 | form | Migration pending | -| [WorkProductsSection.tsx](../../web/src/components/task/WorkProductsSection.tsx) | 504 | authoring | Migration pending | -| [WorkProductsSection.tsx](../../web/src/components/task/WorkProductsSection.tsx) | 551 | authoring | Migration pending | -| [AgentPanel.tsx](../../web/src/components/task/AgentPanel.tsx) | 630 | confirm | Migration pending | -| [AgentPanel.tsx](../../web/src/components/task/AgentPanel.tsx) | 661 | form | Migration pending | -| [dialog.tsx](../../web/src/components/ui/dialog.tsx) | 157 | form | Migration pending | -| [ExportDialog.tsx](../../web/src/components/dashboard/ExportDialog.tsx) | 106 | form | Migration pending | -| [SkillRiskDashboardPanel.tsx](../../web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx) | 339 | form | Shared geometry; browser passed; native pending #1436 | -| [ToolPoliciesTab.tsx](../../web/src/components/settings/tabs/ToolPoliciesTab.tsx) | 269 | authoring | Shared geometry; browser passed; native pending #1436 | -| [GeneralTab.tsx](../../web/src/components/settings/tabs/GeneralTab.tsx) | 404 | confirm | Shared geometry; browser passed; native pending #1436 | -| [TemplateComponents.tsx](../../web/src/components/settings/tabs/TemplateComponents.tsx) | 252 | confirm | Shared geometry; browser passed; native pending #1436 | -| [SecurityTab.tsx](../../web/src/components/settings/tabs/SecurityTab.tsx) | 174 | confirm | Shared geometry; browser passed; native pending #1436 | -| [AgentsTab.tsx](../../web/src/components/settings/tabs/AgentsTab.tsx) | 2331 | confirm | Shared geometry; browser passed; native pending #1436 | -| [MaintenanceTab.tsx](../../web/src/components/settings/tabs/MaintenanceTab.tsx) | 590 | form | Shared geometry; browser passed; native pending #1436 | +| Consumer | Opening line | Intended variant | Status | +| ------------------------------------------------------------------------------------------------- | -----------: | ---------------- | ---------------------------------------------------- | +| [TemplateEditorDialog.tsx](../../web/src/components/templates/TemplateEditorDialog.tsx) | 188 | authoring | Shared primitive adopted; runtime acceptance pending | +| [TemplatesPage.tsx](../../web/src/components/templates/TemplatesPage.tsx) | 274 | confirm | Shared primitive adopted; runtime acceptance pending | +| [TemplatesPage.tsx](../../web/src/components/templates/TemplatesPage.tsx) | 298 | form | Shared primitive adopted; runtime acceptance pending | +| [SearchDialog.tsx](../../web/src/components/search/SearchDialog.tsx) | 282 | authoring | Migration pending; migrate with nested consumers | +| [AdmissionQueuePanel.tsx](../../web/src/components/digest/AdmissionQueuePanel.tsx) | 315 | form | Migration pending | +| [WorkflowStartDialog.tsx](../../web/src/components/workflows/WorkflowStartDialog.tsx) | 71 | form | Migration pending; migrate with nested consumers | +| [CreateTaskDialog.tsx](../../web/src/components/task/CreateTaskDialog.tsx) | 267 | form | Migration pending; migrate with nested consumers | +| [PolicyManager.tsx](../../web/src/components/policies/PolicyManager.tsx) | 552 | authoring | Shared modal; runtime acceptance pending in #1423 | +| [PolicyManager.tsx](../../web/src/components/policies/PolicyManager.tsx) | 906 | form | Shared modal; runtime acceptance pending in #1423 | +| [TimeTrackingSection.tsx](../../web/src/components/task/TimeTrackingSection.tsx) | 243 | form | Migration pending | +| [BulkActionsBar.tsx](../../web/src/components/board/BulkActionsBar.tsx) | 366 | confirm | Migration pending | +| [ArchiveSuggestionBanner.tsx](../../web/src/components/board/ArchiveSuggestionBanner.tsx) | 92 | confirm | Migration pending | +| [ConflictResolver.tsx](../../web/src/components/task/ConflictResolver.tsx) | 124 | utility | Migration pending | +| [ConflictResolver.tsx](../../web/src/components/task/ConflictResolver.tsx) | 383 | confirm | Migration pending | +| [FilterBar.tsx](../../web/src/components/board/FilterBar.tsx) | 349 | form | Migration pending | +| [FilterBar.tsx](../../web/src/components/board/FilterBar.tsx) | 374 | confirm | Migration pending | +| [ChatPanel.tsx](../../web/src/components/chat/ChatPanel.tsx) | 279 | chat | Migration pending; migrate with nested consumers | +| [ChatPanel.tsx](../../web/src/components/chat/ChatPanel.tsx) | 297 | confirm | Migration pending; migrate with nested consumers | +| [TaskWorkView.tsx](../../web/src/components/task/TaskWorkView.tsx) | 1055 | confirm | Migration pending | +| [ObservationsSection.tsx](../../web/src/components/task/ObservationsSection.tsx) | 118 | confirm | Migration pending | +| [SquadChatPanel.tsx](../../web/src/components/chat/SquadChatPanel.tsx) | 522 | chat | Migration pending; migrate with nested consumers | +| [ReviewPanel.tsx](../../web/src/components/task/ReviewPanel.tsx) | 212 | form | Migration pending | +| [ArtifactPreviewModal.tsx](../../web/src/components/task/ArtifactPreviewModal.tsx) | 112 | authoring | Migration pending | +| [CommandPalette.tsx](../../web/src/components/layout/CommandPalette.tsx) | 293 | form | Migration pending; migrate with nested consumers | +| [SettingsDialog.tsx](../../web/src/components/settings/SettingsDialog.tsx) | 529 | authoring | Shared geometry; browser/native passed #1436 | +| [SettingsDialog.tsx](../../web/src/components/settings/SettingsDialog.tsx) | 739 | confirm | Shared geometry; browser/native passed #1436 | +| [PRDialog.tsx](../../web/src/components/task/git/PRDialog.tsx) | 36 | authoring | Migration pending | +| [ActivitySidebar.tsx](../../web/src/components/layout/ActivitySidebar.tsx) | 244 | utility | Migration pending | +| [SortableListItem.tsx](../../web/src/components/settings/SortableListItem.tsx) | 171 | confirm | Shared geometry; browser/native passed #1436 | +| [WorktreeStatus.tsx](../../web/src/components/task/git/WorktreeStatus.tsx) | 374 | form | Migration pending | +| [WorktreeStatus.tsx](../../web/src/components/task/git/WorktreeStatus.tsx) | 404 | confirm | Migration pending | +| [TaskDetailsTab.tsx](../../web/src/components/task/detail/TaskDetailsTab.tsx) | 254 | confirm | Migration pending | +| [KeyboardShortcutsDialog.tsx](../../web/src/components/layout/KeyboardShortcutsDialog.tsx) | 49 | form | Migration pending | +| [ArchiveSidebar.tsx](../../web/src/components/layout/ArchiveSidebar.tsx) | 240 | utility | Migration pending | +| [MobileShell.tsx](../../web/src/components/layout/MobileShell.tsx) | 129 | utility | Migration pending | +| [ApplyTemplateDialog.tsx](../../web/src/components/task/ApplyTemplateDialog.tsx) | 305 | authoring | Migration pending | +| [PreviewPanel.tsx](../../web/src/components/task/PreviewPanel.tsx) | 71 | utility | Migration pending; migrate with nested consumers | +| [AttachmentsSection.tsx](../../web/src/components/task/AttachmentsSection.tsx) | 172 | confirm | Migration pending | +| [SectionHeader.tsx](../../web/src/components/settings/shared/SectionHeader.tsx) | 66 | confirm | Shared geometry; browser/native passed #1436 | +| [DesktopOnboarding.tsx](../../web/src/components/auth/DesktopOnboarding.tsx) | 711 | authoring | Migration pending | +| [AgentRunTimelinePanel.tsx](../../web/src/components/task/AgentRunTimelinePanel.tsx) | 1373 | form | Migration pending | +| [DeliverablesSection.tsx](../../web/src/components/task/DeliverablesSection.tsx) | 272 | confirm | Migration pending | +| [DrillDownPanel.tsx](../../web/src/components/dashboard/DrillDownPanel.tsx) | 14 | utility | Migration pending | +| [WorkflowSection.tsx](../../web/src/components/task/WorkflowSection.tsx) | 266 | authoring | Migration pending | +| [CommentsSection.tsx](../../web/src/components/task/CommentsSection.tsx) | 190 | confirm | Migration pending | +| [TaskDetailPanel.tsx](../../web/src/components/task/TaskDetailPanel.tsx) | 271 | task | Migration pending | +| [sheet.tsx](../../web/src/components/ui/sheet.tsx) | 147 | utility | Migration pending | +| [alert-dialog.tsx](../../web/src/components/ui/alert-dialog.tsx) | 136 | form | Migration pending | +| [WorkProductsSection.tsx](../../web/src/components/task/WorkProductsSection.tsx) | 504 | authoring | Migration pending | +| [WorkProductsSection.tsx](../../web/src/components/task/WorkProductsSection.tsx) | 551 | authoring | Migration pending | +| [AgentPanel.tsx](../../web/src/components/task/AgentPanel.tsx) | 630 | confirm | Migration pending | +| [AgentPanel.tsx](../../web/src/components/task/AgentPanel.tsx) | 661 | form | Migration pending | +| [dialog.tsx](../../web/src/components/ui/dialog.tsx) | 157 | form | Migration pending | +| [ExportDialog.tsx](../../web/src/components/dashboard/ExportDialog.tsx) | 106 | form | Migration pending | +| [SkillRiskDashboardPanel.tsx](../../web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx) | 339 | form | Shared geometry; browser/native passed #1436 | +| [ToolPoliciesTab.tsx](../../web/src/components/settings/tabs/ToolPoliciesTab.tsx) | 269 | authoring | Shared geometry; browser/native passed #1436 | +| [GeneralTab.tsx](../../web/src/components/settings/tabs/GeneralTab.tsx) | 404 | confirm | Shared geometry; browser/native passed #1436 | +| [TemplateComponents.tsx](../../web/src/components/settings/tabs/TemplateComponents.tsx) | 252 | confirm | Shared geometry; browser/native passed #1436 | +| [SecurityTab.tsx](../../web/src/components/settings/tabs/SecurityTab.tsx) | 174 | confirm | Shared geometry; browser/native passed #1436 | +| [AgentsTab.tsx](../../web/src/components/settings/tabs/AgentsTab.tsx) | 2331 | confirm | Shared geometry; browser/native passed #1436 | +| [MaintenanceTab.tsx](../../web/src/components/settings/tabs/MaintenanceTab.tsx) | 590 | form | Shared geometry; browser/native passed #1436 | The mobile notification drawer is a deliberate bottom-sheet exception; it still needs matching spacing and keyboard checks. Compatibility wrappers in `ui/dialog.tsx`, `ui/alert-dialog.tsx`, and `ui/sheet.tsx` remain inventoried above where they instantiate roots; their public call sites must not be silently treated as migrated. Docked Workbench chat is not a modal and must keep its non-modal focus model while adopting the same header/composer spacing. From 6b5b4ed324a30631894e50173341427a04d3caee Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:08:02 -0500 Subject: [PATCH 4/7] fix: keep skill-risk action labels intact --- docs/audits/SETTINGS-RISK-ACTIONS-1438.md | 9 +++ e2e/settings-risk-actions.spec.ts | 77 +++++++++++++++++++ .../settings/tabs/SkillRiskDashboardPanel.tsx | 4 +- 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 docs/audits/SETTINGS-RISK-ACTIONS-1438.md create mode 100644 e2e/settings-risk-actions.spec.ts diff --git a/docs/audits/SETTINGS-RISK-ACTIONS-1438.md b/docs/audits/SETTINGS-RISK-ACTIONS-1438.md new file mode 100644 index 00000000..ab2b1985 --- /dev/null +++ b/docs/audits/SETTINGS-RISK-ACTIONS-1438.md @@ -0,0 +1,9 @@ +# Settings skill-risk actions + +Issue #1438; parent #1389. + +The skill-risk table forced Task and Exception into one non-wrapping flex group. Under constrained Settings widths, the buttons shrank and their labels broke across lines. The group now wraps between buttons, while each action retains its intrinsic width and cannot shrink. Existing variants, icons, handlers, and keyboard semantics are unchanged. + +The rendered regression failed on the original label geometry in both themes. After the correction it passes in both themes at 1700×900/16px, 1180×760/20px, and 900×480/20px. It verifies single-line labels, visible actions, containment, keyboard activation of Exception, and exact focus restoration. All write requests are blocked. Each size opens Settings after resizing to avoid racing the shell's breakpoint focus changes. Shared build, web typecheck, and changed-source lint pass; E2E source is formatted but excluded by the repository ESLint configuration. + +Packaged macOS acceptance and source CI are pending. The installed app, final docs media, and release are unchanged. diff --git a/e2e/settings-risk-actions.spec.ts b/e2e/settings-risk-actions.spec.ts new file mode 100644 index 00000000..c7a3dcf2 --- /dev/null +++ b/e2e/settings-risk-actions.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; +import { bypassAuth, cleanupRoutes } from './helpers/auth'; +import { installSettingsPopoutFixtures } from './helpers/settings-popout-fixtures'; + +test.afterEach(async ({ page }) => cleanupRoutes(page)); + +for (const theme of ['light', 'dark']) { + test(`Skill-risk actions retain intact labels in ${theme}`, async ({ page }, testInfo) => { + await bypassAuth(page); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.addInitScript((theme) => { + localStorage.setItem('veritas-kanban-theme', theme); + Object.defineProperty(window, 'veritasDesktop', { + configurable: true, + value: { onMenuCommand: () => () => undefined }, + }); + }, theme); + let mutations = 0; + await page.route('**/api/**', async (route) => { + if (!['GET', 'HEAD'].includes(route.request().method())) { + mutations++; + await route.abort(); + } else await route.fallback(); + }); + await installSettingsPopoutFixtures(page); + await page.goto('/'); + const opener = page.getByRole('button', { name: 'Settings', exact: true }); + const settings = page.locator('.settings-dialog-content'); + const row = settings.getByRole('row').filter({ hasText: 'Fixture skill' }); + for (const viewport of [ + { width: 1700, height: 900, font: 16 }, + { width: 1180, height: 760, font: 20 }, + { width: 900, height: 480, font: 20 }, + ]) { + await page.setViewportSize(viewport); + await page.evaluate((font) => { + document.documentElement.style.fontSize = `${font}px`; + }, viewport.font); + await opener.click(); + await settings.getByRole('tab', { name: 'Shared Resources', exact: true }).click(); + for (const name of ['Task', 'Exception']) { + const action = row.getByRole('button', { name, exact: true }); + await action.scrollIntoViewIfNeeded(); + await expect(action).toBeInViewport({ ratio: 1 }); + const label = action.locator('.mantine-Button-label'); + expect( + await label.evaluate((el) => { + const range = document.createRange(); + range.selectNodeContents(el); + return ( + range.getBoundingClientRect().height <= + parseFloat(getComputedStyle(el).lineHeight) + 1 + ); + }) + ).toBe(true); + expect(await action.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true); + } + expect(await settings.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true); + const trigger = row.getByRole('button', { name: 'Exception', exact: true }); + await trigger.focus(); + await trigger.press('Enter'); + const dialog = page.getByRole('dialog', { name: 'Exception for Fixture skill', exact: true }); + await expect(dialog).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await expect(trigger).toBeFocused(); + await page.screenshot({ + path: testInfo.outputPath(`risk-actions-${viewport.width}.png`), + animations: 'disabled', + }); + await page.keyboard.press('Escape'); + await expect(settings).toBeHidden(); + await expect(opener).toBeFocused(); + } + expect(mutations).toBe(0); + }); +} diff --git a/web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx b/web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx index f7eeb5f7..a59ca4ae 100644 --- a/web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx +++ b/web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx @@ -310,8 +310,9 @@ export function SkillRiskDashboardPanel() { - + } loading={createTask.isPending} @@ -320,6 +321,7 @@ export function SkillRiskDashboardPanel() { Task } onClick={() => openException(item)} From b4b76b923cfabe3995fe813ea394e4c479acff09 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:10:02 -0500 Subject: [PATCH 5/7] docs: record native skill-risk action acceptance --- docs/audits/SETTINGS-RISK-ACTIONS-1438.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/audits/SETTINGS-RISK-ACTIONS-1438.md b/docs/audits/SETTINGS-RISK-ACTIONS-1438.md index ab2b1985..0b450b17 100644 --- a/docs/audits/SETTINGS-RISK-ACTIONS-1438.md +++ b/docs/audits/SETTINGS-RISK-ACTIONS-1438.md @@ -6,4 +6,12 @@ The skill-risk table forced Task and Exception into one non-wrapping flex group. The rendered regression failed on the original label geometry in both themes. After the correction it passes in both themes at 1700×900/16px, 1180×760/20px, and 900×480/20px. It verifies single-line labels, visible actions, containment, keyboard activation of Exception, and exact focus restoration. All write requests are blocked. Each size opens Settings after resizing to avoid racing the shell's breakpoint focus changes. Shared build, web typecheck, and changed-source lint pass; E2E source is formatted but excluded by the repository ESLint configuration. -Packaged macOS acceptance and source CI are pending. The installed app, final docs media, and release are unchanged. +## Packaged macOS acceptance + +`pnpm desktop:package:mac:dir` passed for integration candidate `87ff80adfa84bf6342df23c932c6ac299bf0f239`. The unsigned arm64 app reports version 6.1.6. Its packaged web index SHA-256 is `ce875f5cb4c0b17a9bfbc506bdec053ebb7674f84bf3abead2f042361f8f5fec`; the Settings bundle `SettingsDialog-BEBS0fhe.js` is `edf17be583847b6765eec3c4d9bfbdf05b47e7aef34ce2acc4733fcaa8a87476`. These identify the separately packaged web assets; `app.asar` contains the desktop wrapper and is not a UI asset identifier. + +The real packaged app passed the focused action-label checks in both themes at 1700×900/16px and the native minimum 1180×760/20px. Task and Exception labels stay on one line, actions remain fully visible, Settings has no horizontal overflow, and Exception keyboard opening/Escape restores the exact opener. Both enlarged-text captures were visually inspected. The disposable profile observed zero unexpected mutations and the owned app was closed afterward. + +Native command: `NATIVE_SCOPE=risk-actions pnpm --filter @veritas-kanban/server exec tsx /tmp/vk-native-ui-1389.8cJNtK/verify.mjs` from the integration worktree. Diagnostic images are `/private/tmp/vk-native-ui-1389.8cJNtK/settings-risk-actions-{light,dark}-{1700,1180}.png`, not final maintained media. Both specification and standards source reviews found no outstanding findings. + +Source CI and the parent Settings PR are pending. The installed app, final docs media, remaining audit work, and release are unchanged. From 76e575393715c31724b1676539c9fef4d5a0c7a1 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:20:53 -0500 Subject: [PATCH 6/7] fix: contain Settings risk table scrolling --- docs/audits/SETTINGS-RISK-ACTIONS-1438.md | 8 +- e2e/settings-risk-actions.spec.ts | 9 + .../settings/tabs/SkillRiskDashboardPanel.tsx | 230 +++++++++--------- 3 files changed, 135 insertions(+), 112 deletions(-) diff --git a/docs/audits/SETTINGS-RISK-ACTIONS-1438.md b/docs/audits/SETTINGS-RISK-ACTIONS-1438.md index 0b450b17..7f175c03 100644 --- a/docs/audits/SETTINGS-RISK-ACTIONS-1438.md +++ b/docs/audits/SETTINGS-RISK-ACTIONS-1438.md @@ -10,8 +10,14 @@ The rendered regression failed on the original label geometry in both themes. Af `pnpm desktop:package:mac:dir` passed for integration candidate `87ff80adfa84bf6342df23c932c6ac299bf0f239`. The unsigned arm64 app reports version 6.1.6. Its packaged web index SHA-256 is `ce875f5cb4c0b17a9bfbc506bdec053ebb7674f84bf3abead2f042361f8f5fec`; the Settings bundle `SettingsDialog-BEBS0fhe.js` is `edf17be583847b6765eec3c4d9bfbdf05b47e7aef34ce2acc4733fcaa8a87476`. These identify the separately packaged web assets; `app.asar` contains the desktop wrapper and is not a UI asset identifier. -The real packaged app passed the focused action-label checks in both themes at 1700×900/16px and the native minimum 1180×760/20px. Task and Exception labels stay on one line, actions remain fully visible, Settings has no horizontal overflow, and Exception keyboard opening/Escape restores the exact opener. Both enlarged-text captures were visually inspected. The disposable profile observed zero unexpected mutations and the owned app was closed afterward. +The real packaged app passed the focused action-label checks in both themes at 1700×900/16px and the native minimum 1180×760/20px. Task and Exception labels stay on one line, actions remain fully visible, the outer Settings dialog has no horizontal overflow, and Exception keyboard opening/Escape restores the exact opener. This original check did not inspect the inner Settings content scroller; the correction below addresses that gap. Both enlarged-text captures were visually inspected. The disposable profile observed zero unexpected mutations and the owned app was closed afterward. Native command: `NATIVE_SCOPE=risk-actions pnpm --filter @veritas-kanban/server exec tsx /tmp/vk-native-ui-1389.8cJNtK/verify.mjs` from the integration worktree. Diagnostic images are `/private/tmp/vk-native-ui-1389.8cJNtK/settings-risk-actions-{light,dark}-{1700,1180}.png`, not final maintained media. Both specification and standards source reviews found no outstanding findings. Source CI and the parent Settings PR are pending. The installed app, final docs media, remaining audit work, and release are unchanged. + +## Inner content containment correction + +Integration QA run `33830608672` exposed horizontal panning inside Settings. A stronger check on `[data-settings-content-scroll]` failed in both themes: the intrinsic table width escaped its card and widened the entire Settings content scroller. The table now has its own native, keyboard-focusable, named scroll region with a text-scaled minimum width. Buttons retain their intrinsic widths and a small horizontal scroll margin; measured nearest-edge scrolling otherwise clipped 0.75px of the Exception button. + +The revised exact browser regression passes in both themes at all three sizes above, with zero retries and zero write requests. It checks the inner and outer containment boundaries, ArrowRight scrolling within the table, full action visibility, single-line labels, and Exception activation/dismissal focus. Web typecheck and changed-source lint pass. The new table-scroller delta still requires packaged macOS acceptance; the earlier native evidence does not cover it. diff --git a/e2e/settings-risk-actions.spec.ts b/e2e/settings-risk-actions.spec.ts index c7a3dcf2..ccc5320e 100644 --- a/e2e/settings-risk-actions.spec.ts +++ b/e2e/settings-risk-actions.spec.ts @@ -38,6 +38,14 @@ for (const theme of ['light', 'dark']) { }, viewport.font); await opener.click(); await settings.getByRole('tab', { name: 'Shared Resources', exact: true }).click(); + await expect(row).toBeVisible(); + const content = settings.locator('[data-settings-content-scroll]'); + expect(await content.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true); + const inventory = settings.getByRole('region', { name: 'Skill risk inventory', exact: true }); + await inventory.focus(); + await expect(inventory).toBeFocused(); + await inventory.press('ArrowRight'); + await expect.poll(() => inventory.evaluate((el) => el.scrollLeft)).toBeGreaterThan(0); for (const name of ['Task', 'Exception']) { const action = row.getByRole('button', { name, exact: true }); await action.scrollIntoViewIfNeeded(); @@ -56,6 +64,7 @@ for (const theme of ['light', 'dark']) { expect(await action.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true); } expect(await settings.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true); + expect(await content.evaluate((el) => el.scrollLeft)).toBe(0); const trigger = row.getByRole('button', { name: 'Exception', exact: true }); await trigger.focus(); await trigger.press('Enter'); diff --git a/web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx b/web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx index a59ca4ae..ed890117 100644 --- a/web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx +++ b/web/src/components/settings/tabs/SkillRiskDashboardPanel.tsx @@ -218,122 +218,130 @@ export function SkillRiskDashboardPanel() { - - - - Skill - Scan - Risk - Capabilities - Install gate - Action - - - - {sortedItems.map((item) => ( - - - - {item.name} - - - v{item.version} · {item.sourcePath} - - {item.remediationTaskId && ( - - task {item.remediationTaskId} - - )} - - - - - {item.scanStatus} - - {item.changedFiles.length > 0 && ( - - changed + +
+ + + Skill + Scan + Risk + Capabilities + Install gate + Action + + + + {sortedItems.map((item) => ( + + + + {item.name} + + + v{item.version} · {item.sourcePath} + + {item.remediationTaskId && ( + + task {item.remediationTaskId} )} - - - {formatDate(item.lastScannedAt)} - - - - - {item.severity} - - - score {item.riskScore} · {item.findingCount} findings - - - - + + - - declared - - + + {item.scanStatus} + + {item.changedFiles.length > 0 && ( + + changed + + )} - - - observed - - + + {formatDate(item.lastScannedAt)} + + + + + {item.severity} + + + score {item.riskScore} · {item.findingCount} findings + + + + + + + declared + + + + + + observed + + + + + + + + {item.installDecision} + + + {item.exception + ? `${item.exception.owner} until ${formatDate(item.exception.expiresAt)}` + : item.installReason} + + + + + } + loading={createTask.isPending} + onClick={() => handleCreateTask(item)} + > + Task + + } + onClick={() => openException(item)} + > + Exception + - - - - - {item.installDecision} - - - {item.exception - ? `${item.exception.owner} until ${formatDate(item.exception.expiresAt)}` - : item.installReason} - - - - - } - loading={createTask.isPending} - onClick={() => handleCreateTask(item)} - > - Task - - } - onClick={() => openException(item)} - > - Exception - - - - - ))} - -
+
+ + ))} + + + )} From 0857d6d3f1827a7b8856c8c96a7accbe2db4fd62 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:22:47 -0500 Subject: [PATCH 7/7] docs: record native risk table containment acceptance --- docs/audits/SETTINGS-RISK-ACTIONS-1438.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/audits/SETTINGS-RISK-ACTIONS-1438.md b/docs/audits/SETTINGS-RISK-ACTIONS-1438.md index 7f175c03..fca0fe2c 100644 --- a/docs/audits/SETTINGS-RISK-ACTIONS-1438.md +++ b/docs/audits/SETTINGS-RISK-ACTIONS-1438.md @@ -20,4 +20,6 @@ Source CI and the parent Settings PR are pending. The installed app, final docs Integration QA run `33830608672` exposed horizontal panning inside Settings. A stronger check on `[data-settings-content-scroll]` failed in both themes: the intrinsic table width escaped its card and widened the entire Settings content scroller. The table now has its own native, keyboard-focusable, named scroll region with a text-scaled minimum width. Buttons retain their intrinsic widths and a small horizontal scroll margin; measured nearest-edge scrolling otherwise clipped 0.75px of the Exception button. -The revised exact browser regression passes in both themes at all three sizes above, with zero retries and zero write requests. It checks the inner and outer containment boundaries, ArrowRight scrolling within the table, full action visibility, single-line labels, and Exception activation/dismissal focus. Web typecheck and changed-source lint pass. The new table-scroller delta still requires packaged macOS acceptance; the earlier native evidence does not cover it. +The revised exact browser regression passes in both themes at all three sizes above, with zero retries and zero write requests. It checks the inner and outer containment boundaries, ArrowRight scrolling within the table, full action visibility, single-line labels, and Exception activation/dismissal focus. Web typecheck and changed-source lint pass. Both specification and standards reviews found no actionable findings. + +The corrected integration candidate `f12214f4eaae13c92397721dad69d1a74da4db8d` passed `pnpm desktop:package:mac:dir` and the strengthened `NATIVE_SCOPE=risk-actions` check in both themes at 1700×900/16px and 1180×760/20px. Its packaged web index SHA-256 is `fbb6891717c7bf5343909f54c9d6de801726083513748f2325826a22f7be2788`; `SettingsDialog-DsKfXoJc.js` is `e643cb830a1e7f86c0d1e0e19af5acfc822d3806357d020115b2d454d90db3fe` and matches the built source asset byte-for-byte. Native checks now explicitly prove no horizontal overflow or panning in the inner Settings content scroller, local keyboard scrolling, intact actions, and Exception focus restoration. Both enlarged-text captures were inspected. Zero unexpected mutations occurred; the disposable app was closed. This supersedes the narrower original containment evidence, but does not replace the installed app or refresh final maintained media.