Merge pull request #1439 from BradGroux/fix/settings-risk-actions-1438

fix: keep Settings skill-risk action labels intact
This commit is contained in:
Brad Groux 2026-09-04 07:24:33 -05:00 • committed by GitHub
commit 538695c833
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 230 additions and 109 deletions

View file

@ -0,0 +1,25 @@
# 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
`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, 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. 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.

View file

@ -0,0 +1,86 @@
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();
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();
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);
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');
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);
});
}

View file

@ -218,120 +218,130 @@ export function SkillRiskDashboardPanel() {
</UiPill>
</SimpleGrid>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Skill</Table.Th>
<Table.Th>Scan</Table.Th>
<Table.Th>Risk</Table.Th>
<Table.Th>Capabilities</Table.Th>
<Table.Th>Install gate</Table.Th>
<Table.Th>Action</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{sortedItems.map((item) => (
<Table.Tr key={item.skillId}>
<Table.Td>
<Text size="sm" fw={600}>
{item.name}
</Text>
<Text size="xs" c="dimmed">
v{item.version} · {item.sourcePath}
</Text>
{item.remediationTaskId && (
<UiPill kind="status" tone="info">
task {item.remediationTaskId}
</UiPill>
)}
</Table.Td>
<Table.Td>
<Group gap={4}>
<UiPill
kind="status"
tone={semanticToneForLegacyColor(
item.scanStatus === 'scanned' ? 'green' : 'orange'
)}
>
{item.scanStatus}
</UiPill>
{item.changedFiles.length > 0 && (
<UiPill kind="status" tone="warning">
changed
<Table.ScrollContainer
minWidth="52rem"
type="native"
role="region"
aria-label="Skill risk inventory"
tabIndex={0}
>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Skill</Table.Th>
<Table.Th>Scan</Table.Th>
<Table.Th>Risk</Table.Th>
<Table.Th>Capabilities</Table.Th>
<Table.Th>Install gate</Table.Th>
<Table.Th>Action</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{sortedItems.map((item) => (
<Table.Tr key={item.skillId}>
<Table.Td>
<Text size="sm" fw={600}>
{item.name}
</Text>
<Text size="xs" c="dimmed">
v{item.version} · {item.sourcePath}
</Text>
{item.remediationTaskId && (
<UiPill kind="status" tone="info">
task {item.remediationTaskId}
</UiPill>
)}
</Group>
<Text size="xs" c="dimmed">
{formatDate(item.lastScannedAt)}
</Text>
</Table.Td>
<Table.Td>
<UiPill
kind="status"
tone={semanticToneForLegacyColor(SEVERITY_COLORS[item.severity])}
>
{item.severity}
</UiPill>
<Text size="xs" c="dimmed">
score {item.riskScore} · {item.findingCount} findings
</Text>
</Table.Td>
<Table.Td>
<Stack gap={4}>
</Table.Td>
<Table.Td>
<Group gap={4}>
<Text size="xs" c="dimmed">
declared
</Text>
<CapabilityBadges values={item.declaredCapabilities} />
<UiPill
kind="status"
tone={semanticToneForLegacyColor(
item.scanStatus === 'scanned' ? 'green' : 'orange'
)}
>
{item.scanStatus}
</UiPill>
{item.changedFiles.length > 0 && (
<UiPill kind="status" tone="warning">
changed
</UiPill>
)}
</Group>
<Group gap={4}>
<Text size="xs" c="dimmed">
observed
</Text>
<CapabilityBadges values={observedCapabilities(item)} />
<Text size="xs" c="dimmed">
{formatDate(item.lastScannedAt)}
</Text>
</Table.Td>
<Table.Td>
<UiPill
kind="status"
tone={semanticToneForLegacyColor(SEVERITY_COLORS[item.severity])}
>
{item.severity}
</UiPill>
<Text size="xs" c="dimmed">
score {item.riskScore} · {item.findingCount} findings
</Text>
</Table.Td>
<Table.Td>
<Stack gap={4}>
<Group gap={4}>
<Text size="xs" c="dimmed">
declared
</Text>
<CapabilityBadges values={item.declaredCapabilities} />
</Group>
<Group gap={4}>
<Text size="xs" c="dimmed">
observed
</Text>
<CapabilityBadges values={observedCapabilities(item)} />
</Group>
</Stack>
</Table.Td>
<Table.Td>
<UiPill
kind="status"
tone={
item.installDecision === 'block'
? 'blocked'
: semanticToneForLegacyColor(DECISION_COLORS[item.installDecision])
}
>
{item.installDecision}
</UiPill>
<Text size="xs" c="dimmed">
{item.exception
? `${item.exception.owner} until ${formatDate(item.exception.expiresAt)}`
: item.installReason}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="wrap">
<UiAction
className="w-max shrink-0 scroll-mx-2"
variant="secondary"
leftSection={<ClipboardList className="h-3.5 w-3.5" />}
loading={createTask.isPending}
onClick={() => handleCreateTask(item)}
>
Task
</UiAction>
<UiAction
className="w-max shrink-0 scroll-mx-2"
variant="quiet"
leftSection={<AlertTriangle className="h-3.5 w-3.5" />}
onClick={() => openException(item)}
>
Exception
</UiAction>
</Group>
</Stack>
</Table.Td>
<Table.Td>
<UiPill
kind="status"
tone={
item.installDecision === 'block'
? 'blocked'
: semanticToneForLegacyColor(DECISION_COLORS[item.installDecision])
}
>
{item.installDecision}
</UiPill>
<Text size="xs" c="dimmed">
{item.exception
? `${item.exception.owner} until ${formatDate(item.exception.expiresAt)}`
: item.installReason}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<UiAction
variant="secondary"
leftSection={<ClipboardList className="h-3.5 w-3.5" />}
loading={createTask.isPending}
onClick={() => handleCreateTask(item)}
>
Task
</UiAction>
<UiAction
variant="quiet"
leftSection={<AlertTriangle className="h-3.5 w-3.5" />}
onClick={() => openException(item)}
>
Exception
</UiAction>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</>
)}
</Stack>