fix: bound every header string, and make scrollable panels keyboard-reachable (#2263)

Copilot review round 8: the skill URI was still unbounded in the pane's fixed
header. It is the fourth instance of a pattern named in this PR's own round-7
reply — anything server-controlled and variable-height that sits beside the
zero-basis accordion sizes the layout — and it was directly above the
description clamped last round, which says something about naming a class not
being the same as sweeping for it.

So this audits the whole header rather than the reported element. All three
server-controlled strings are now bounded: the name truncates (already did),
the URI clamps to two lines, the description to three, and both clamps carry
the full value on a `title`. `HostileHeader` asserts the class is closed — a
long name, a 30-segment URI and a 400-word description, with the accordion
still taking more than half the card, every section non-zero, and the pane not
scrolling.

That new story then caught an accessibility defect this refactor introduced and
would otherwise have shipped: `scrollable-region-focusable`. Making each panel
its own scroll container means a panel whose content holds no focusable element
— the Conformance alerts, for instance — cannot be scrolled by keyboard at all.
Every `Accordion.Panel` now carries `tabIndex={0}`.

The geometric assertion lives in Storybook rather than the unit test, because a
CSS bound is not observable through happy-dom's `getComputedStyle`; the unit
test pins the contract that makes clamping safe instead — the full value stays
reachable on a `title`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4441JdNLZerCVM7mDDSfB
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-06 02:03:44 -04:00
commit e8bbcf115a
3 changed files with 146 additions and 17 deletions

View file

@ -185,18 +185,6 @@ const LONG_SKILL_MD = [
), ),
].join("\n"); ].join("\n");
/**
* The layout contract, asserted in a real browser.
*
* This is the regression the refactor exists to prevent, and it is only visible
* with content that overflows: the file viewer must scroll **inside its own
* panel** while its sibling sections keep usable height, rather than the whole
* pane scrolling as one column.
*
* It also pins the collapse-then-reopen case, which is how the original bug
* actually presented — the viewer's content-sized `flex-basis` crushed its
* siblings, so collapsing it laid out correctly and reopening it broke again.
*/
// A conforming manifest may declare up to 512 files. This is the sibling case // A conforming manifest may declare up to 512 files. This is the sibling case
// to a long document: the metadata section, not the viewer, is what holds the // to a long document: the metadata section, not the viewer, is what holds the
// overflowing content. // overflowing content.
@ -271,6 +259,85 @@ export const LongManifest: Story = {
}, },
}; };
// A skill whose every server-controlled header string is hostile: a very long
// name, a URI with many breakable segments, and a description at the upper end
// of what SEP-2640 permits.
const HOSTILE_NAME = "an-extremely-long-skill-name-".repeat(8);
const hostileHeaderSkill: SkillEntry = {
uri: `skill://${"very-long-path-segment/".repeat(30)}SKILL.md`,
frontmatter: {
name: HOSTILE_NAME,
description: "word ".repeat(400).trim(),
},
resources: [
{
uri: `skill://${"very-long-path-segment/".repeat(30)}SKILL.md`,
digest: SELF_DIGEST,
size: 8,
},
],
};
/**
* The pane's fixed header cannot starve the accordion.
*
* The header is a sibling of an accordion whose flex-basis is `0`, so every
* unbounded string in it is subtracted from the sections rather than resisted
* by them. That has been the same defect three times in this PR — the viewer's
* content-sized basis, the `skills/get` region, and the description — so this
* asserts the whole class is closed rather than any one instance.
*/
export const HostileHeader: Story = {
args: {
skills: [hostileHeaderSkill],
onReadSkillFile: fn(async () => ({
text: "---\nname: x\n---\n\n# Body\n",
mimeType: "text/markdown",
})),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getAllByText(HOSTILE_NAME)[0]);
await canvas.findByRole("button", { name: /Skill Resource/ });
const detailCard = canvasElement.querySelectorAll(".mantine-Card-root")[1];
const accordion = canvasElement.querySelector(".disclosure-sections");
if (
!(detailCard instanceof HTMLElement) ||
!(accordion instanceof HTMLElement)
) {
throw new Error("detail card or accordion not found");
}
// The header takes a minority of the pane, leaving the sections the rest.
const cardHeight = detailCard.getBoundingClientRect().height;
const accordionHeight = accordion.getBoundingClientRect().height;
await expect(accordionHeight).toBeGreaterThan(cardHeight * 0.5);
// Every section is still usable, and the pane still does not scroll.
for (const item of accordion.querySelectorAll(
":scope > .mantine-Accordion-item",
)) {
await expect(item.getBoundingClientRect().height).toBeGreaterThan(0);
}
await expect(detailCard.scrollHeight).toBeLessThanOrEqual(
detailCard.clientHeight + 1,
);
},
};
/**
* The layout contract, asserted in a real browser.
*
* This is the regression the refactor exists to prevent, and it is only visible
* with content that overflows: the file viewer must scroll **inside its own
* panel** while its sibling sections keep usable height, rather than the whole
* pane scrolling as one column.
*
* It also pins the collapse-then-reopen case, which is how the original bug
* actually presented — the viewer's content-sized `flex-basis` crushed its
* siblings, so collapsing it laid out correctly and reopening it broke again.
*/
export const LongSkillDocument: Story = { export const LongSkillDocument: Story = {
args: { args: {
onReadSkillFile: fn(async () => ({ onReadSkillFile: fn(async () => ({

View file

@ -362,6 +362,47 @@ describe("SkillsScreen", () => {
); );
}); });
it("bounds every server-controlled string in the fixed header", async () => {
// The header sits beside an accordion whose flex-basis is 0, so anything
// unbounded here is subtracted from the sections rather than resisted by
// them. This has been the same bug three times over (#2263) — the viewer's
// content-sized basis, the `skills/get` region, the description — so this
// asserts the *class* is closed rather than chasing one more instance.
const user = userEvent.setup();
const hostile: SkillEntry = {
uri: `skill://${"very-long-segment/".repeat(40)}SKILL.md`,
frontmatter: {
name: "x".repeat(300),
// SEP-2640 permits 1,024 characters here.
description: "word ".repeat(400).trim(),
},
resources: [
{
uri: `skill://${"very-long-segment/".repeat(40)}SKILL.md`,
digest: SELF_DIGEST,
size: textToBytes(SELF_TEXT).byteLength,
},
],
};
renderWithMantine(<ControlledSkillsScreen skills={[hostile]} />);
await user.click(screen.getAllByText("x".repeat(300))[0]);
// The *geometric* bound is a CSS concern and belongs in a real browser —
// `HostileHeader` in the stories asserts the header cannot starve the
// accordion. What is worth pinning here is the contract that makes
// clamping safe: the full value stays reachable on a `title`, so nothing
// is actually hidden from the user.
expect(screen.getByTitle(hostile.uri)).toBeInTheDocument();
expect(
screen.getByTitle(hostile.frontmatter.description as string),
).toBeInTheDocument();
// And the header still shows all three, rather than dropping any.
const detail = screen.getByTestId("skill-detail");
expect(detail.textContent).toContain("xxxx");
expect(detail.textContent).toContain("skill://very-long-segment");
expect(detail.textContent).toContain("word word");
});
it("badges a warning-only entry yellow, not green", async () => { it("badges a warning-only entry yellow, not green", async () => {
// Green reads as "nothing to see", which would hide the only signal the // Green reads as "nothing to see", which would hide the only signal the
// section carries for an entry whose findings are all warnings (#2263). // section carries for an entry whose findings are all warnings (#2263).

View file

@ -273,6 +273,16 @@ const MonoCaption = Text.withProps({
variant: "monoCaption", variant: "monoCaption",
}); });
// The selected skill's URI, clamped for the same reason as its description
// below — it is the OTHER server-controlled string in the pane's fixed header,
// and a URI with many breakable path segments wraps just as freely as prose.
// A skill URI is usually one line, so two is generous; the full value stays on
// the `title` the call site passes.
const SkillUriCaption = Text.withProps({
variant: "monoCaption",
lineClamp: 2,
});
// The skill's description, clamped. // The skill's description, clamped.
// //
// It sits in the pane's FIXED header, beside an accordion whose basis is zero, // It sits in the pane's FIXED header, beside an accordion whose basis is zero,
@ -1120,7 +1130,9 @@ export function SkillsScreen({
<SectionControlsRow> <SectionControlsRow>
<Stack gap={4}> <Stack gap={4}>
<SkillTitle>{skillDisplayName(selected)}</SkillTitle> <SkillTitle>{skillDisplayName(selected)}</SkillTitle>
<MonoCaption>{selected.uri}</MonoCaption> <SkillUriCaption title={selected.uri}>
{selected.uri}
</SkillUriCaption>
</Stack> </Stack>
{/* Both actions act on the whole skill, so they live on the {/* Both actions act on the whole skill, so they live on the
pane's header rather than inside a section — and a button pane's header rather than inside a section — and a button
@ -1168,6 +1180,12 @@ export function SkillsScreen({
headers stay pinned and each open panel scrolls within its own headers stay pinned and each open panel scrolls within its own
share of the space, which is exactly what keeps this pane from share of the space, which is exactly what keeps this pane from
scrolling as one column. */} scrolling as one column. */}
{/* Each `Accordion.Panel` is a scroll container under the
`skillSections` variant, so every one carries `tabIndex={0}`:
axe's `scrollable-region-focusable` requires a scrollable region
to be keyboard-reachable, and a panel whose content holds no
focusable element (the Conformance alerts, for instance) is
otherwise unscrollable without a pointer. */}
<Accordion <Accordion
multiple multiple
variant="skillSections" variant="skillSections"
@ -1205,7 +1223,7 @@ export function SkillsScreen({
)} )}
</InlineRow> </InlineRow>
</Accordion.Control> </Accordion.Control>
<Accordion.Panel> <Accordion.Panel tabIndex={0}>
<ConformanceStack> <ConformanceStack>
{/* A dynamic skill's `dynamic-resources` finding is {/* A dynamic skill's `dynamic-resources` finding is
rendered here in full rather than as a bare code and rendered here in full rather than as a bare code and
@ -1393,7 +1411,7 @@ export function SkillsScreen({
</CountBadge> </CountBadge>
</InlineRow> </InlineRow>
</Accordion.Control> </Accordion.Control>
<Accordion.Panel> <Accordion.Panel tabIndex={0}>
<Stack gap="xs"> <Stack gap="xs">
<ManifestTable data-testid="skill-manifest"> <ManifestTable data-testid="skill-manifest">
<Table.Thead> <Table.Thead>
@ -1520,7 +1538,7 @@ export function SkillsScreen({
<Accordion.Control> <Accordion.Control>
<SectionHeading>Frontmatter</SectionHeading> <SectionHeading>Frontmatter</SectionHeading>
</Accordion.Control> </Accordion.Control>
<Accordion.Panel> <Accordion.Panel tabIndex={0}>
<FramedContent> <FramedContent>
{/* The raw YAML the server served, not a re-serialised {/* The raw YAML the server served, not a re-serialised
object: this app carries no YAML parser, and for a object: this app carries no YAML parser, and for a
@ -1559,7 +1577,10 @@ export function SkillsScreen({
)} )}
</SectionControlsRow> </SectionControlsRow>
</Accordion.Control> </Accordion.Control>
<Accordion.Panel data-testid="skill-resource-viewer"> <Accordion.Panel
tabIndex={0}
data-testid="skill-resource-viewer"
>
{previewError !== undefined ? ( {previewError !== undefined ? (
<Alert color="red" title="Could not read this resource"> <Alert color="red" title="Could not read this resource">
{previewError} {previewError}