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:
parent
52e8e1b557
commit
e8bbcf115a
3 changed files with 146 additions and 17 deletions
|
|
@ -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 () => ({
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue