feat: refactor the Skills detail pane into collapsible sections (#2263)
The pane was one scrolling column: SKILL.md sat below the manifest behind a "View SKILL.md" button, the frontmatter pushed it further out of view, and nothing could be put away. It is now a flex column that does not scroll as a whole. Conformance, Resources, Frontmatter and Skill Resource are four collapsible sections of one `disclosure` accordion, and the file viewer claims the remaining height and scrolls internally. The viewer's flex-basis is 0, not auto, and that is load-bearing: `auto` makes an item's basis its content height, and a rendered document is ~1,795px for the data-analysis fixture. That basis joined the sum the container distributes, over-constraining the column so the shrink factors crushed the siblings — Conformance to a 2px panel, Resources to zero — with their contents spilling over the headers below. Collapsing the viewer removed the giant basis, so the symptom appeared on collapse-then-reopen. Open sections also carry a `mih` floor, since the disclosure CSS sets `min-height: 0` on an active item. Frontmatter now follows the displayed file rather than the selected skill. Both halves come from one `splitSkillFile` call, so the section and the viewer cannot disagree, and a file with no frontmatter renders no section at all. Also: SKILL.md loads on selection (a `resources/read` is not a load under SEP-2640), the manifest highlights on hover with each URI a button that swaps the viewed file, both whole-skill actions moved to the pane header, and the Conformance badge goes yellow for a warnings-only entry instead of green. Flat CSS properties moved out of call sites into theme variants (ThemeText gains four; a new ThemeTable takes the manifest font size). 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
229e71588d
commit
e67eb3408e
9 changed files with 1070 additions and 311 deletions
|
|
@ -17,7 +17,10 @@ import {
|
|||
import { EMPTY_SKILLS_UI } from "../screenUiState";
|
||||
|
||||
const REF_TEXT = "# Column rules\n";
|
||||
const SELF_TEXT = "# data-analysis\n";
|
||||
// A real SKILL.md carries frontmatter, and the screen now splits it out of the
|
||||
// served bytes (#2263) — so the fixture has to have some, or the Frontmatter
|
||||
// section it drives would never render here.
|
||||
const SELF_TEXT = "---\nname: data-analysis\n---\n\n# data-analysis\n";
|
||||
const NOTES_TEXT = "different\n";
|
||||
// Computed once at module load so each fixture's advertised digest really is
|
||||
// the digest of the bytes the fake read returns — a hard-coded constant would
|
||||
|
|
@ -138,6 +141,14 @@ function ControlledSkillsScreen(props: Partial<SkillsScreenProps> = {}) {
|
|||
);
|
||||
}
|
||||
|
||||
// Mantine puts a Badge's colour on the ROOT as CSS custom properties, while
|
||||
// `getByText` matches the inner label span — so the colour has to be read from
|
||||
// the enclosing root rather than from the matched node.
|
||||
function badgeStyle(text: RegExp): string {
|
||||
const root = screen.getByText(text).closest(".mantine-Badge-root");
|
||||
return root?.getAttribute("style") ?? "";
|
||||
}
|
||||
|
||||
describe("SkillsScreen", () => {
|
||||
it("renders the empty state until a skill is selected", () => {
|
||||
renderWithMantine(<SkillsScreen {...baseProps} />);
|
||||
|
|
@ -199,6 +210,28 @@ describe("SkillsScreen", () => {
|
|||
expect(screen.queryByTestId("skill-issues")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("badges a warning-only entry yellow, not green", async () => {
|
||||
// Green reads as "nothing to see", which would hide the only signal the
|
||||
// section carries for an entry whose findings are all warnings (#2263).
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("dynamic-report"));
|
||||
// `dynamic-resources` is a warning, and the only finding on this fixture.
|
||||
const style = badgeStyle(/0 error\(s\), 1 warning\(s\)/);
|
||||
expect(style).toContain("yellow");
|
||||
expect(style).not.toContain("green");
|
||||
});
|
||||
|
||||
it("badges a clean entry green and a broken one red", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
expect(badgeStyle(/0 error\(s\), 0 warning\(s\)/)).toContain("green");
|
||||
|
||||
await user.click(screen.getByText("right-name"));
|
||||
expect(badgeStyle(/1 error\(s\), 0 warning\(s\)/)).toContain("red");
|
||||
});
|
||||
|
||||
it("shows the name/path mismatch as a distinct, named finding", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
|
|
@ -262,7 +295,10 @@ describe("SkillsScreen", () => {
|
|||
await user.click(screen.getByRole("button", { name: /Verify all/ }));
|
||||
// One alert per file in the manifest — both reads failed.
|
||||
expect(await screen.findAllByText("Could not read file")).toHaveLength(2);
|
||||
expect(screen.getAllByText("403")).toHaveLength(2);
|
||||
// Three, not two: the same rejecting read also serves the SKILL.md the
|
||||
// viewer loads on selection (#2263), so the message appears once per
|
||||
// manifest row plus once in the viewer.
|
||||
expect(screen.getAllByText("403")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("wraps a non-Error read rejection", async () => {
|
||||
|
|
@ -273,7 +309,9 @@ describe("SkillsScreen", () => {
|
|||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Verify all/ }));
|
||||
expect(await screen.findAllByText("plain string")).toHaveLength(2);
|
||||
// Three: one per manifest row, plus the viewer's own auto-loaded SKILL.md
|
||||
// read, which the same mock rejects (#2263).
|
||||
expect(await screen.findAllByText("plain string")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("titles a size disagreement a size mismatch, not a digest one", async () => {
|
||||
|
|
@ -336,7 +374,7 @@ describe("SkillsScreen", () => {
|
|||
|
||||
it("renders a base64 SKILL.md preview instead of a blank one", async () => {
|
||||
// `onReadSkillFile` supports blob content, and verification reads it
|
||||
// correctly; dropping it in the preview would paint an empty box for a
|
||||
// correctly; dropping it in the viewer would paint an empty box for a
|
||||
// file the screen had just checked.
|
||||
const user = userEvent.setup();
|
||||
const onReadSkillFile = vi.fn().mockResolvedValue({
|
||||
|
|
@ -347,9 +385,8 @@ describe("SkillsScreen", () => {
|
|||
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
|
||||
const preview = await screen.findByTestId("skill-md-preview");
|
||||
expect(preview).toHaveTextContent("from a blob");
|
||||
const viewer = screen.getByTestId("skill-resource-viewer");
|
||||
await waitFor(() => expect(viewer).toHaveTextContent("from a blob"));
|
||||
});
|
||||
|
||||
it("keeps the newest verdict when two verifications of one row overlap", async () => {
|
||||
|
|
@ -382,18 +419,21 @@ describe("SkillsScreen", () => {
|
|||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
// Selecting the skill already issued the viewer's own SKILL.md read
|
||||
// (#2263), so the two clicks below are the reads AFTER that one.
|
||||
const base = resolvers.length;
|
||||
const rowVerify = screen.getByRole("button", {
|
||||
name: "Verify skill://data-analysis/SKILL.md",
|
||||
});
|
||||
await user.click(rowVerify);
|
||||
await user.click(rowVerify);
|
||||
expect(resolvers).toHaveLength(2);
|
||||
expect(resolvers).toHaveLength(base + 2);
|
||||
|
||||
// The SECOND read answers first with the matching bytes, then the first
|
||||
// read answers with bytes that would verify as a mismatch.
|
||||
resolvers[1]({ text: SELF_TEXT });
|
||||
resolvers[base + 1]({ text: SELF_TEXT });
|
||||
expect(await screen.findByText("verified")).toBeInTheDocument();
|
||||
resolvers[0]({ text: "stale bytes\n" });
|
||||
resolvers[base]({ text: "stale bytes\n" });
|
||||
// Still the newer verdict.
|
||||
expect(await screen.findByText("verified")).toBeInTheDocument();
|
||||
expect(screen.queryByText("mismatch")).not.toBeInTheDocument();
|
||||
|
|
@ -608,19 +648,20 @@ describe("SkillsScreen", () => {
|
|||
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
const view = screen.getByRole("button", { name: /View SKILL.md/ });
|
||||
// Past the viewer's own read for the selection (#2263).
|
||||
const base = resolvers.length;
|
||||
const view = screen.getByRole("button", {
|
||||
name: "skill://data-analysis/reference.md",
|
||||
});
|
||||
await user.click(view);
|
||||
await user.click(view);
|
||||
expect(resolvers).toHaveLength(2);
|
||||
expect(resolvers).toHaveLength(base + 2);
|
||||
|
||||
resolvers[1]({ text: "# newest\n" });
|
||||
expect(await screen.findByTestId("skill-md-preview")).toHaveTextContent(
|
||||
"newest",
|
||||
);
|
||||
resolvers[0]({ text: "# stale\n" });
|
||||
expect(screen.getByTestId("skill-md-preview")).not.toHaveTextContent(
|
||||
"stale",
|
||||
);
|
||||
const viewer = screen.getByTestId("skill-resource-viewer");
|
||||
resolvers[base + 1]({ text: "# newest\n" });
|
||||
await waitFor(() => expect(viewer).toHaveTextContent("newest"));
|
||||
resolvers[base]({ text: "# stale\n" });
|
||||
expect(viewer).not.toHaveTextContent("stale");
|
||||
});
|
||||
|
||||
it("keeps the newest skills/get result when two fetches overlap", async () => {
|
||||
|
|
@ -804,18 +845,21 @@ describe("SkillsScreen", () => {
|
|||
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
const view = screen.getByRole("button", { name: /View SKILL.md/ });
|
||||
// Past the viewer's own read for the selection (#2263).
|
||||
const base = resolvers.length;
|
||||
const view = screen.getByRole("button", {
|
||||
name: "skill://data-analysis/reference.md",
|
||||
});
|
||||
await user.click(view);
|
||||
await user.click(view);
|
||||
expect(resolvers).toHaveLength(2);
|
||||
expect(resolvers).toHaveLength(base + 2);
|
||||
|
||||
const viewer = screen.getByTestId("skill-resource-viewer");
|
||||
// The OLDER read answers first, while the newer one is still in flight.
|
||||
resolvers[0]({ text: "# stale\n" });
|
||||
expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument();
|
||||
resolvers[1]({ text: "# newest\n" });
|
||||
expect(await screen.findByTestId("skill-md-preview")).toHaveTextContent(
|
||||
"newest",
|
||||
);
|
||||
resolvers[base]({ text: "# stale\n" });
|
||||
expect(viewer).not.toHaveTextContent("stale");
|
||||
resolvers[base + 1]({ text: "# newest\n" });
|
||||
await waitFor(() => expect(viewer).toHaveTextContent("newest"));
|
||||
});
|
||||
|
||||
it("rejects an older skills/get even when it resolves FIRST", async () => {
|
||||
|
|
@ -849,12 +893,111 @@ describe("SkillsScreen", () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the SKILL.md preview on demand", async () => {
|
||||
it("shows the skill's own SKILL.md as soon as it is selected", async () => {
|
||||
// No button to press (#2263): the viewer opens on the skill's own file, so
|
||||
// selecting it is the whole interaction.
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
|
||||
expect(await screen.findByTestId("skill-md-preview")).toBeInTheDocument();
|
||||
const viewer = screen.getByTestId("skill-resource-viewer");
|
||||
await waitFor(() => expect(viewer).toHaveTextContent("data-analysis"));
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /View SKILL.md/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("heads the viewer with the displayed file, not the section's purpose", async () => {
|
||||
// The heading is static so it does not change shape as the file changes;
|
||||
// the file name sits beside it (#2263).
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
// The heading lives on the section's control (it is the collapsible
|
||||
// section's own header), so it is queried at screen level rather than
|
||||
// inside the panel.
|
||||
const control = within(
|
||||
screen.getByRole("button", { name: /Skill Resource/ }),
|
||||
);
|
||||
expect(control.getByText("Skill Resource")).toBeInTheDocument();
|
||||
expect(control.getByText("SKILL.md")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("swaps the displayed file when a manifest URI is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
const viewer = screen.getByTestId("skill-resource-viewer");
|
||||
await waitFor(() => expect(viewer).toHaveTextContent("data-analysis"));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "skill://data-analysis/reference.md",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(viewer).toHaveTextContent("Column rules"));
|
||||
// The section header follows the file, and the previous contents are gone.
|
||||
expect(
|
||||
within(screen.getByRole("button", { name: /Skill Resource/ })).getByText(
|
||||
"reference.md",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(viewer).not.toHaveTextContent("data-analysis");
|
||||
});
|
||||
|
||||
it("shows the frontmatter of the file on display, and hides the section when it has none", async () => {
|
||||
// Both halves come from one split (#2263), so the section can never show
|
||||
// one file's frontmatter beside another file's body — and the viewer never
|
||||
// repeats what the section is already showing.
|
||||
const user = userEvent.setup();
|
||||
const onReadSkillFile = vi.fn(async (uri: string) =>
|
||||
uri.endsWith("reference.md")
|
||||
? { text: "# Ref\n\nNo frontmatter here.\n" }
|
||||
: { text: "---\nname: data-analysis\n---\n\n# The body\n" },
|
||||
);
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
const viewer = screen.getByTestId("skill-resource-viewer");
|
||||
await waitFor(() => expect(viewer).toHaveTextContent("The body"));
|
||||
// Shown once, in its own section — not again in the viewer.
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Frontmatter/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(viewer).not.toHaveTextContent("name: data-analysis");
|
||||
|
||||
// reference.md has no frontmatter, so the section goes away entirely
|
||||
// rather than lingering with SKILL.md's fields.
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "skill://data-analysis/reference.md",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(viewer).toHaveTextContent("No frontmatter here"),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Frontmatter/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the row whose file the viewer is showing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
const self = screen.getByRole("button", {
|
||||
name: "skill://data-analysis/SKILL.md",
|
||||
});
|
||||
const other = screen.getByRole("button", {
|
||||
name: "skill://data-analysis/reference.md",
|
||||
});
|
||||
// The skill's own file is what the viewer opens on, so its row is current.
|
||||
expect(self).toHaveAttribute("aria-current", "true");
|
||||
expect(other).not.toHaveAttribute("aria-current");
|
||||
|
||||
await user.click(other);
|
||||
await waitFor(() => expect(other).toHaveAttribute("aria-current", "true"));
|
||||
expect(self).not.toHaveAttribute("aria-current");
|
||||
});
|
||||
|
||||
it("reports a failed SKILL.md read", async () => {
|
||||
|
|
@ -864,10 +1007,10 @@ describe("SkillsScreen", () => {
|
|||
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
|
||||
expect(
|
||||
await screen.findByText("Could not read SKILL.md"),
|
||||
await screen.findByText("Could not read this resource"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("gone")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("wraps a non-Error SKILL.md rejection", async () => {
|
||||
|
|
@ -877,10 +1020,36 @@ describe("SkillsScreen", () => {
|
|||
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
|
||||
expect(await screen.findByText("bare")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the three sections independently collapsible", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
// Conformance and Resources open by default; Frontmatter is closed.
|
||||
expect(screen.getByRole("button", { name: /Conformance/ })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Frontmatter/ })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
|
||||
// Collapsing one leaves the others alone — `multiple`, not a single-open
|
||||
// accordion.
|
||||
await user.click(screen.getByRole("button", { name: /Conformance/ }));
|
||||
expect(screen.getByRole("button", { name: /Conformance/ })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Resources/ })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops verification results when the selection changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
|
|
@ -895,14 +1064,27 @@ describe("SkillsScreen", () => {
|
|||
expect(screen.getAllByText("—")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("drops the SKILL.md preview when the selection changes", async () => {
|
||||
it("re-points the viewer at the newly selected skill's own file", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
const onReadSkillFile = vi.fn(async (uri: string) => ({
|
||||
text: `contents of ${uri}\n`,
|
||||
}));
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
|
||||
expect(await screen.findByTestId("skill-md-preview")).toBeInTheDocument();
|
||||
const viewer = screen.getByTestId("skill-resource-viewer");
|
||||
await waitFor(() =>
|
||||
expect(viewer).toHaveTextContent("contents of skill://data-analysis"),
|
||||
);
|
||||
|
||||
// The previous skill's contents must not survive the switch: the viewer
|
||||
// follows the selection rather than holding whatever was last read.
|
||||
await user.click(screen.getByText("tampered"));
|
||||
expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(viewer).toHaveTextContent("contents of skill://tampered/SKILL.md"),
|
||||
);
|
||||
expect(viewer).not.toHaveTextContent("contents of skill://data-analysis");
|
||||
});
|
||||
|
||||
it("renders an em dash for a manifest entry with no size or digest", async () => {
|
||||
|
|
@ -1032,10 +1214,15 @@ describe("SkillsScreen", () => {
|
|||
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
|
||||
// `release` now holds the resolver for the SECOND skill's auto-read; the
|
||||
// first skill's is stranded, which is the point — resolving the older one
|
||||
// must not publish into the newer selection.
|
||||
const stale = release;
|
||||
await user.click(screen.getByText("tampered"));
|
||||
release?.({ text: SELF_TEXT });
|
||||
expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument();
|
||||
stale?.({ text: "# from the abandoned skill\n" });
|
||||
expect(screen.getByTestId("skill-resource-viewer")).not.toHaveTextContent(
|
||||
"abandoned",
|
||||
);
|
||||
});
|
||||
|
||||
it("discards a failed SKILL.md read that resolves after the selection moved on", async () => {
|
||||
|
|
@ -1051,9 +1238,10 @@ describe("SkillsScreen", () => {
|
|||
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
|
||||
// The abandoned skill's own read, stranded by the selection change below.
|
||||
const stale = fail;
|
||||
await user.click(screen.getByText("tampered"));
|
||||
fail?.(new Error("too late"));
|
||||
stale?.(new Error("too late"));
|
||||
expect(screen.queryByText("too late")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
14
clients/web/src/theme/Table.ts
Normal file
14
clients/web/src/theme/Table.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Table } from "@mantine/core";
|
||||
|
||||
export const ThemeTable = Table.extend({
|
||||
// The Skills resource manifest (#2263). `striped`, `withTableBorder`,
|
||||
// `highlightOnHover` and `verticalSpacing` are Mantine's own API and stay
|
||||
// props at the call site; the font size is a flat CSS property, so it lives
|
||||
// here rather than as an `fz` prop on the screen's `.withProps()` constant.
|
||||
styles: (_theme, props) => {
|
||||
if (props.variant === "manifest") {
|
||||
return { table: { fontSize: "var(--mantine-font-size-xs)" } };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
|
|
@ -42,6 +42,42 @@ export const ThemeText = Text.extend({
|
|||
// footer's `space-between` Group. Both are grey, single-line, and out of the
|
||||
// text-selection flow. (Superseded the fixed bottom-corner badges of #1639
|
||||
// now that the footer is a real, full-width AppShell row.)
|
||||
// The four typographic treatments the Skills screen repeats (#2263). They
|
||||
// live here rather than as `fw`/`size`/`c`/`ff` props on the screen's
|
||||
// `.withProps()` constants because they are flat CSS properties, which
|
||||
// AGENTS.md places in the theme; the constants keep only layout.
|
||||
//
|
||||
// `sectionHeading` labels a collapsible section; `skillTitle` names the
|
||||
// selected skill; `monoCaption` is the dimmed monospace line used for URIs
|
||||
// and digests; `emptyState` is the centred placeholder shown before a
|
||||
// selection exists.
|
||||
if (props.variant === "sectionHeading") {
|
||||
return {
|
||||
root: { fontWeight: 600, fontSize: "var(--mantine-font-size-sm)" },
|
||||
};
|
||||
}
|
||||
if (props.variant === "skillTitle") {
|
||||
return {
|
||||
root: { fontWeight: 600, fontSize: "var(--mantine-font-size-lg)" },
|
||||
};
|
||||
}
|
||||
if (props.variant === "monoCaption") {
|
||||
return {
|
||||
root: {
|
||||
fontSize: "var(--mantine-font-size-xs)",
|
||||
fontFamily: "var(--mantine-font-family-monospace)",
|
||||
color: "var(--inspector-text-secondary)",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (props.variant === "emptyState") {
|
||||
return {
|
||||
root: {
|
||||
color: "var(--inspector-text-secondary)",
|
||||
textAlign: "center",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
props.variant === "versionBadge" ||
|
||||
props.variant === "copyrightBadge"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export { ThemePaper } from "./Paper";
|
|||
export { ThemeScrollArea, ThemeScrollAreaAutosize } from "./ScrollArea";
|
||||
export { ThemeSelect } from "./Select";
|
||||
export { ThemeSwitch } from "./Switch";
|
||||
export { ThemeTable } from "./Table";
|
||||
export { ThemeText } from "./Text";
|
||||
export { ThemeTextInput } from "./TextInput";
|
||||
export { ThemeTitle } from "./Title";
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
ThemeScrollAreaAutosize,
|
||||
ThemeSelect,
|
||||
ThemeSwitch,
|
||||
ThemeTable,
|
||||
ThemeText,
|
||||
ThemeTextInput,
|
||||
ThemeTitle,
|
||||
|
|
@ -201,6 +202,7 @@ export const theme = createTheme({
|
|||
ScrollAreaAutosize: ThemeScrollAreaAutosize,
|
||||
Select: ThemeSelect,
|
||||
Switch: ThemeSwitch,
|
||||
Table: ThemeTable,
|
||||
Text: ThemeText,
|
||||
TextInput: ThemeTextInput,
|
||||
Title: ThemeTitle,
|
||||
|
|
|
|||
66
clients/web/src/utils/splitSkillFile.test.ts
Normal file
66
clients/web/src/utils/splitSkillFile.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { splitSkillFile } from "./splitSkillFile";
|
||||
|
||||
describe("splitSkillFile", () => {
|
||||
it("separates a leading frontmatter fence from the body", () => {
|
||||
expect(splitSkillFile("---\nname: a\n---\n\n# Title\n\nBody\n")).toEqual({
|
||||
frontmatter: "name: a",
|
||||
body: "# Title\n\nBody\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports no frontmatter for a file that has none", () => {
|
||||
expect(splitSkillFile("# Title\n\nBody\n")).toEqual({
|
||||
body: "# Title\n\nBody\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an unterminated opening fence alone rather than eating the file", () => {
|
||||
// `---` with no closing fence is not frontmatter. Treating it as such would
|
||||
// truncate the document to nothing, which is far worse than showing it.
|
||||
const text = "---\nnot really frontmatter\n\n# Title\n";
|
||||
expect(splitSkillFile(text)).toEqual({ body: text });
|
||||
});
|
||||
|
||||
it("does not treat a horizontal rule mid-file as frontmatter", () => {
|
||||
const text = "# Title\n\n---\n\nAfter the rule\n";
|
||||
expect(splitSkillFile(text)).toEqual({ body: text });
|
||||
});
|
||||
|
||||
it("handles CRLF line endings", () => {
|
||||
expect(splitSkillFile("---\r\nname: a\r\n---\r\n\r\n# Title\r\n")).toEqual({
|
||||
frontmatter: "name: a",
|
||||
body: "# Title\r\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty body when the file is nothing but frontmatter", () => {
|
||||
expect(splitSkillFile("---\nname: a\n---\n")).toEqual({
|
||||
frontmatter: "name: a",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles a closing fence with no trailing newline", () => {
|
||||
// The file ends ON the fence, so there is no newline after it to split at.
|
||||
expect(splitSkillFile("---\nname: a\n---")).toEqual({
|
||||
frontmatter: "name: a",
|
||||
body: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a body that follows the fence with no blank line", () => {
|
||||
// The blank line between fence and body is a convention, not a rule —
|
||||
// stripping unconditionally would eat the first line of a file without one.
|
||||
expect(splitSkillFile("---\nname: a\n---\nBody\n")).toEqual({
|
||||
frontmatter: "name: a",
|
||||
body: "Body\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps every frontmatter line, not just the first", () => {
|
||||
expect(
|
||||
splitSkillFile("---\nname: a\ndescription: b\n---\n\nBody\n"),
|
||||
).toEqual({ frontmatter: "name: a\ndescription: b", body: "Body\n" });
|
||||
});
|
||||
});
|
||||
55
clients/web/src/utils/splitSkillFile.ts
Normal file
55
clients/web/src/utils/splitSkillFile.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Split a skill file into its YAML frontmatter and its body (#2263).
|
||||
*
|
||||
* A pure transform with no I/O and no subsystem of its own, so it lives in
|
||||
* `utils/` rather than `lib/` — and in its own module rather than in
|
||||
* `SkillsScreen.tsx`, because a component file that also exports a function
|
||||
* defeats React Fast Refresh (`react-refresh/only-export-components`).
|
||||
*/
|
||||
|
||||
export interface SkillFileParts {
|
||||
/**
|
||||
* The raw YAML between the fences, fences excluded — `undefined` when the
|
||||
* file has no frontmatter at all. Raw rather than parsed: this app carries no
|
||||
* YAML parser, and showing the bytes the server actually served is the more
|
||||
* useful answer for a conformance tool anyway.
|
||||
*/
|
||||
frontmatter?: string;
|
||||
/** Everything after the closing fence, or the whole file when there is none. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separate a leading YAML frontmatter fence from the rest of a skill file.
|
||||
*
|
||||
* The Skills screen renders the two halves in different places — the
|
||||
* frontmatter in its own collapsible section, the body in the file viewer — and
|
||||
* deriving both from **one** split is what stops them disagreeing: the section
|
||||
* can never show one file's frontmatter while the viewer shows another's, and a
|
||||
* file with no frontmatter cannot leave a stale section on screen.
|
||||
*
|
||||
* It also matters for rendering: the markdown renderer has no frontmatter
|
||||
* support, so an un-split `---\nname: …\n---` is read as a setext heading and
|
||||
* painted as a title above the document's real one.
|
||||
*
|
||||
* Two deliberate conservatisms, because this must never eat content:
|
||||
*
|
||||
* - Only a fence at the very **start** of the file counts. A `---` anywhere
|
||||
* else is a horizontal rule and is left in the body.
|
||||
* - A file that opens with `---` but never closes the fence is **not**
|
||||
* frontmatter; the whole file is returned as the body rather than being
|
||||
* truncated to nothing.
|
||||
*/
|
||||
export function splitSkillFile(text: string): SkillFileParts {
|
||||
if (!/^---[ \t]*\r?\n/.test(text)) return { body: text };
|
||||
const rest = text.slice(text.indexOf("\n") + 1);
|
||||
const close = rest.search(/^---[ \t]*\r?$/m);
|
||||
if (close === -1) return { body: text };
|
||||
const frontmatter = rest.slice(0, close).replace(/\r?\n$/, "");
|
||||
const after = rest.slice(close);
|
||||
const newline = after.indexOf("\n");
|
||||
if (newline === -1) return { frontmatter, body: "" };
|
||||
// Drop the blank line conventionally left between the fence and the body, so
|
||||
// the document does not open with dead space.
|
||||
return { frontmatter, body: after.slice(newline + 1).replace(/^\r?\n/, "") };
|
||||
}
|
||||
|
|
@ -126,9 +126,74 @@ const DATA_ANALYSIS_FM: Frontmatter = {
|
|||
name: "data-analysis",
|
||||
description: "Analyze a CSV and summarize its columns",
|
||||
};
|
||||
// Deliberately the long one. Every other fixture file here is two or three
|
||||
// lines, which is not enough to exercise the detail pane's resource viewer:
|
||||
// with a short file the viewer never scrolls, so a regression that let the
|
||||
// whole pane scroll instead — the thing #2263 fixed — would look identical to
|
||||
// the fix. One fixture has to be taller than the viewport for that difference
|
||||
// to be visible at all.
|
||||
const DATA_ANALYSIS_MD = skillMd(
|
||||
DATA_ANALYSIS_FM,
|
||||
"# Data analysis\n\nLoad the CSV, then follow `reference.md` for the column rules.",
|
||||
[
|
||||
"# Data analysis",
|
||||
"",
|
||||
"Load the CSV, then follow `reference.md` for the column rules.",
|
||||
"",
|
||||
"## Reading the file",
|
||||
"",
|
||||
"Read the file as UTF-8 and sniff the delimiter from the header line rather",
|
||||
"than assuming a comma: exports from spreadsheet tools frequently use a",
|
||||
"semicolon, and a mis-sniffed delimiter yields a single column whose name is",
|
||||
"the entire header, which then reads as a valid — if useless — result.",
|
||||
"",
|
||||
"If the first line is not a header, every column name becomes a data value",
|
||||
"and the row count is off by one. Prefer an explicit `has_header` flag over a",
|
||||
"heuristic when the caller can supply it.",
|
||||
"",
|
||||
"## Typing the columns",
|
||||
"",
|
||||
"Infer a column's type from a sample rather than from its first value. A",
|
||||
"column of integers with one empty cell is still numeric; a column of numbers",
|
||||
"with a single stray `N/A` is not, and coercing it silently turns a data",
|
||||
"quality problem into a wrong answer.",
|
||||
"",
|
||||
"Treat these as missing: the empty string, `NA`, `N/A`, `null`, and `-`.",
|
||||
"Anything else that fails to parse is a value, not a gap, and belongs in the",
|
||||
"report as such.",
|
||||
"",
|
||||
"## Summarising",
|
||||
"",
|
||||
"Numeric columns get min, max, mean and a null count. Report the null count",
|
||||
"even when it is zero — its absence is indistinguishable from a column that",
|
||||
"was skipped, and a reader cannot tell which they are looking at.",
|
||||
"",
|
||||
"Text columns get a distinct-value count and the five most common values with",
|
||||
"their frequencies. Cap the distinct count: a free-text column can have as",
|
||||
"many distinct values as rows, and enumerating them is not a summary.",
|
||||
"",
|
||||
"Date columns get an earliest and a latest. Do not attempt a mean of dates.",
|
||||
"",
|
||||
"## Reporting",
|
||||
"",
|
||||
"Lead with the shape — rows, columns — then the per-column detail. A reader",
|
||||
"scanning the top of the report should learn whether the file is what they",
|
||||
"expected before they read anything else.",
|
||||
"",
|
||||
"State the delimiter and the encoding you used. When either was guessed, say",
|
||||
"that it was guessed: a summary computed from a mis-parsed file is worse than",
|
||||
"no summary, because it looks the same as a correct one.",
|
||||
"",
|
||||
"## Failure modes worth naming",
|
||||
"",
|
||||
"A ragged file — rows with differing column counts — is a parse failure, not",
|
||||
"a row to drop quietly. Report the first offending line number.",
|
||||
"",
|
||||
"A file whose every column types as text usually means the delimiter was",
|
||||
"wrong. Say so rather than reporting fifty text columns as a finding.",
|
||||
"",
|
||||
"An empty file is not an error, but a summary of it must say the file was",
|
||||
"empty rather than returning zeroed statistics that read like real ones.",
|
||||
].join("\n"),
|
||||
);
|
||||
const DATA_ANALYSIS_REF =
|
||||
"# Column rules\n\nNumeric columns get min/max/mean; text columns get a value count.\n";
|
||||
|
|
|
|||
Loading…
Reference in a new issue