From 1199809e737ca3eef124bbbbd81673b4fd9f5c89 Mon Sep 17 00:00:00 2001 From: DCCA Date: Sat, 18 Jul 2026 17:37:52 -0300 Subject: [PATCH 1/2] feat: add a component library for saved composites Saved composites had no home. They appeared as a flat row of pills in the toolbar - a name and three icon buttons each - which does not survive more than a handful of components and never shows what any of them look like. There was no search, no grouping, no duplicate, no way to export the set, and no sense of what you had worked on recently. The library is a full-screen panel of cards, each rendering a live themed preview of the component, with search across name/description/category, category chips, duplicate, per-component and bulk export, and most-recently-updated ordering. The toolbar pill row is replaced by a single Library button carrying a count; every action the pills offered is still available on the cards. The panel is purely presentational - it takes composites and callbacks and never touches storage or the registry, so the page remains the only owner of those side effects. Theming is applied per card to each preview element rather than to the document, and each preview has its own error boundary so one broken definition cannot take the library down. CompositeDefinition gains optional category, createdAt and updatedAt. All three are optional deliberately: components saved before this change have none of them and must keep working, so a missing timestamp sorts last and renders as '-' rather than causing the entry to be dropped. saveComposite stamps updatedAt, preserves createdAt across edits, and returns the stamped definition so callers track what was actually written. Also fixes a rendering bug this made obvious: composites stack nodes in a column flex whose default align-items:stretch was blowing the inline-block badge and button primitives out to full width, in previews and in exported output alike. --- __tests__/composite/identity.test.ts | 66 ++++ __tests__/sandbox/component-library.test.tsx | 214 +++++++++++ app/sandbox/page.tsx | 78 +++- components/composite/primitive-renderers.tsx | 7 + components/sandbox/component-builder.tsx | 32 +- components/sandbox/component-library.tsx | 335 ++++++++++++++++++ components/sandbox/toolbar.tsx | 74 ++-- .../2026-07-18-component-library-design.md | 89 +++++ e2e/component-library.spec.ts | 103 ++++++ e2e/composite-staleness.spec.ts | 6 +- lib/composite/identity.ts | 35 ++ lib/composite/io.ts | 11 +- lib/composite/storage.ts | 17 +- lib/composite/types.ts | 9 + 14 files changed, 999 insertions(+), 77 deletions(-) create mode 100644 __tests__/composite/identity.test.ts create mode 100644 __tests__/sandbox/component-library.test.tsx create mode 100644 components/sandbox/component-library.tsx create mode 100644 docs/superpowers/specs/2026-07-18-component-library-design.md create mode 100644 e2e/component-library.spec.ts create mode 100644 lib/composite/identity.ts diff --git a/__tests__/composite/identity.test.ts b/__tests__/composite/identity.test.ts new file mode 100644 index 0000000..1bde360 --- /dev/null +++ b/__tests__/composite/identity.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { newCompositeId, duplicateComposite } from "@/lib/composite/identity"; +import type { CompositeDefinition } from "@/lib/composite/types"; + +const def: CompositeDefinition = { + id: "custom_price_tag_abc", + name: "Price Tag", + description: "A tag", + category: "Commerce", + version: "1.0", + nodes: [{ id: "n1", type: "heading", props: { text: "Hi", level: "h2" } }], + propBindings: [ + { + propKey: "title", + label: "Title", + type: "string", + required: true, + targetPath: [0, "props", "text"], + }, + ], + createdAt: 1000, + updatedAt: 2000, +}; + +describe("newCompositeId", () => { + it("produces a readable, prefixed, unique-ish key", () => { + const id = newCompositeId("Price Tag"); + expect(id).toMatch(/^custom_price_tag_[a-z0-9]+$/); + }); + + it("falls back to a placeholder when the name has nothing sluggable", () => { + expect(newCompositeId("!!!")).toMatch(/^custom_custom_component_[a-z0-9]+$/); + }); +}); + +describe("duplicateComposite", () => { + it("gives the copy a new id so saving it doesn't overwrite the original", () => { + const copy = duplicateComposite(def, [def.name]); + + expect(copy.id).not.toBe(def.id); + expect(copy.name).toBe("Price Tag copy"); + }); + + it("avoids colliding with names already in the library", () => { + const copy = duplicateComposite(def, ["Price Tag", "Price Tag copy", "Price Tag copy 2"]); + + expect(copy.name).toBe("Price Tag copy 3"); + }); + + it("carries over the structure and category but not the timestamps", () => { + const copy = duplicateComposite(def, []); + + expect(copy.nodes).toEqual(def.nodes); + expect(copy.propBindings).toEqual(def.propBindings); + expect(copy.category).toBe("Commerce"); + expect(copy.createdAt).toBeUndefined(); + expect(copy.updatedAt).toBeUndefined(); + }); + + it("deep-clones so editing the copy cannot mutate the original", () => { + const copy = duplicateComposite(def, []); + copy.nodes[0].props.text = "Changed"; + + expect(def.nodes[0].props.text).toBe("Hi"); + }); +}); diff --git a/__tests__/sandbox/component-library.test.tsx b/__tests__/sandbox/component-library.test.tsx new file mode 100644 index 0000000..375b842 --- /dev/null +++ b/__tests__/sandbox/component-library.test.tsx @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from "vitest"; +import { render, screen, fireEvent, cleanup, within } from "@testing-library/react"; +import { axe, toHaveNoViolations } from "jest-axe"; +import { ComponentLibrary } from "@/components/sandbox/component-library"; +import type { CompositeDefinition } from "@/lib/composite/types"; +import type { ThemeTokens } from "@/lib/theme/types"; + +expect.extend(toHaveNoViolations); + +// Theming and composite rendering are covered by their own suites; stubbing them keeps +// this file about the library surface (filtering, sorting, actions, isolation). +vi.mock("@/lib/theme/css-vars", () => ({ applyTheme: vi.fn() })); +vi.mock("@/components/composite/composite-renderer", () => ({ + CompositeRenderer: ({ definition }: { definition: CompositeDefinition }) => { + if (definition.id === "broken") throw new Error("bad definition"); + return
; + }, +})); + +afterEach(cleanup); + +const HOUR = 60 * 60 * 1000; +const now = Date.now(); + +// jsdom has no layout, so contrast/region rules cannot be evaluated meaningfully. +const axeOptions = { + rules: { + "color-contrast": { enabled: false }, + region: { enabled: false }, + "heading-order": { enabled: false }, + }, +}; + +function def(overrides: Partial & { id: string; name: string }) { + return { + nodes: [{ id: "n1", type: "paragraph" as const, props: { text: overrides.name } }], + propBindings: [], + version: "1.0" as const, + ...overrides, + } satisfies CompositeDefinition; +} + +const alpha = def({ + id: "alpha", + name: "Alpha Card", + description: "A promo card", + category: "Marketing", + updatedAt: now - 2 * HOUR, +}); +const beta = def({ + id: "beta", + name: "Beta Banner", + description: "Top of page banner", + category: "Layout", + updatedAt: now - 30 * 1000, +}); +// No category and no timestamp: the pre-library shape. +const gamma = def({ id: "gamma", name: "Gamma Legacy" }); + +const noop = () => {}; + +function renderLibrary(overrides: Partial[0]> = {}) { + const props = { + composites: [alpha, beta, gamma], + themeTokens: {} as ThemeTokens, + onEdit: vi.fn(), + onDuplicate: vi.fn(), + onDelete: vi.fn(), + onExportOne: vi.fn(), + onExportAll: vi.fn(), + onCreate: vi.fn(), + onClose: vi.fn(), + ...overrides, + }; + return { ...render(), props }; +} + +function cardNames(container: HTMLElement): string[] { + return [...container.querySelectorAll("h3")].map((h) => h.textContent ?? ""); +} + +describe("ComponentLibrary", () => { + it("lists every saved composite", () => { + const { container } = renderLibrary(); + expect(cardNames(container)).toEqual( + expect.arrayContaining(["Alpha Card", "Beta Banner", "Gamma Legacy"]), + ); + }); + + it("sorts most-recently-updated first and undated last", () => { + const { container } = renderLibrary(); + expect(cardNames(container)).toEqual(["Beta Banner", "Alpha Card", "Gamma Legacy"]); + }); + + it("shows a relative updated line, and '-' when the timestamp is missing", () => { + renderLibrary(); + expect(screen.getByText("Updated 2 hours ago")).toBeDefined(); + expect(screen.getByText("Updated just now")).toBeDefined(); + expect(screen.getByText("Updated -")).toBeDefined(); + }); + + it("filters by name, description and category, case-insensitively", () => { + const { container } = renderLibrary(); + const search = screen.getByLabelText("Search"); + + fireEvent.change(search, { target: { value: "alpha" } }); + expect(cardNames(container)).toEqual(["Alpha Card"]); + + fireEvent.change(search, { target: { value: "TOP OF PAGE" } }); + expect(cardNames(container)).toEqual(["Beta Banner"]); + + fireEvent.change(search, { target: { value: "marketing" } }); + expect(cardNames(container)).toEqual(["Alpha Card"]); + }); + + it("filters by category, grouping the untagged ones under Uncategorized", () => { + const { container } = renderLibrary(); + + fireEvent.click(screen.getByRole("button", { name: "Layout" })); + expect(cardNames(container)).toEqual(["Beta Banner"]); + + fireEvent.click(screen.getByRole("button", { name: "Uncategorized" })); + expect(cardNames(container)).toEqual(["Gamma Legacy"]); + + fireEvent.click(screen.getByRole("button", { name: "All" })); + expect(cardNames(container)).toHaveLength(3); + }); + + it("offers Create when the library is empty", () => { + const { props } = renderLibrary({ composites: [] }); + expect(screen.getByText("No components yet")).toBeDefined(); + expect(screen.queryByLabelText("Search")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Create your first component" })); + expect(props.onCreate).toHaveBeenCalled(); + }); + + it("distinguishes an empty search result and clears the search", () => { + const { container } = renderLibrary(); + fireEvent.change(screen.getByLabelText("Search"), { target: { value: "nothing matches" } }); + + expect(screen.getByText("No components match this search")).toBeDefined(); + expect(screen.queryByText("No components yet")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Clear search" })); + expect(cardNames(container)).toHaveLength(3); + }); + + it("fires each per-card action with that composite", () => { + const { props } = renderLibrary(); + + fireEvent.click(screen.getByTitle("Edit Alpha Card")); + expect(props.onEdit).toHaveBeenCalledWith(alpha); + + fireEvent.click(screen.getByTitle("Duplicate Alpha Card")); + expect(props.onDuplicate).toHaveBeenCalledWith(alpha); + + fireEvent.click(screen.getByTitle("Export Alpha Card")); + expect(props.onExportOne).toHaveBeenCalledWith(alpha); + + fireEvent.click(screen.getByTitle("Delete Alpha Card")); + expect(props.onDelete).toHaveBeenCalledWith("alpha"); + }); + + it("fires the header actions", () => { + const { props } = renderLibrary(); + + fireEvent.click(screen.getByTitle("Export all components")); + expect(props.onExportAll).toHaveBeenCalled(); + + fireEvent.click(screen.getByTitle("New component")); + expect(props.onCreate).toHaveBeenCalled(); + + fireEvent.click(screen.getByTitle("Close component library")); + expect(props.onClose).toHaveBeenCalled(); + }); + + it("has no axe violations", async () => { + const { container } = renderLibrary(); + expect(await axe(container, axeOptions)).toHaveNoViolations(); + }); +}); + +describe("ComponentLibrary preview isolation", () => { + let errorSpy: ReturnType; + + beforeAll(() => { + // React logs the caught render error; the boundary is the thing under test. + errorSpy = vi.spyOn(console, "error").mockImplementation(noop); + }); + afterAll(() => errorSpy.mockRestore()); + + it("keeps the library up when one composite fails to render", () => { + const broken = def({ id: "broken", name: "Broken One", updatedAt: now }); + const { container } = render( + , + ); + + expect(cardNames(container)).toEqual(["Broken One", "Alpha Card"]); + expect(screen.getByText("bad definition")).toBeDefined(); + // The healthy sibling still renders its preview. + expect(within(container).getByTestId("preview-alpha")).toBeDefined(); + }); +}); diff --git a/app/sandbox/page.tsx b/app/sandbox/page.tsx index 47cfb71..a144b05 100644 --- a/app/sandbox/page.tsx +++ b/app/sandbox/page.tsx @@ -32,6 +32,10 @@ import { saveComposite, deleteComposite as deleteCompositeFromStorage, } from "@/lib/composite/storage"; +import { duplicateComposite } from "@/lib/composite/identity"; +import { exportComposite, exportComposites } from "@/lib/composite/io"; +import { slugify } from "@/lib/utils"; +import { ComponentLibrary } from "@/components/sandbox/component-library"; import { CompositeRenderer } from "@/components/composite/composite-renderer"; import type { CompositeDefinition } from "@/lib/composite/types"; import { useHistory } from "@/lib/sandbox/use-history"; @@ -106,6 +110,7 @@ function SandboxContent() { const [selectedSectionId, setSelectedSectionId] = useState(null); const [composites, setComposites] = useState([]); const [builderOpen, setBuilderOpen] = useState(false); + const [libraryOpen, setLibraryOpen] = useState(false); const [editingComposite, setEditingComposite] = useState(null); const [urlWarning, setUrlWarning] = useState(null); const [storageWarning, setStorageWarning] = useState(null); @@ -206,6 +211,11 @@ function SandboxContent() { }, [parsedSections, parseFailed]); const sectionsForUsage = parseFailed ? lastGoodSections.current : parsedSections; + const knownCategories = useMemo( + () => [...new Set(composites.map((d) => d.category).filter((c): c is string => !!c))].sort(), + [composites], + ); + // Derive selected section + registry item const selectedSection = useMemo(() => { if (!selectedSectionId) return null; @@ -526,34 +536,58 @@ function SandboxContent() { }, []); const handleSaveComposite = useCallback((def: CompositeDefinition) => { - const comp = createCompositeComponent(def); - registerComposite(def, comp); - saveComposite(def); + registerComposite(def, createCompositeComponent(def)); + // Keep state on the stamped version so "recently updated" ordering is right + // without waiting for a reload. + const stamped = saveComposite(def); setComposites((prev) => { - const idx = prev.findIndex((d) => d.id === def.id); + const idx = prev.findIndex((d) => d.id === stamped.id); if (idx >= 0) { const next = [...prev]; - next[idx] = def; + next[idx] = stamped; return next; } - return [...prev, def]; + return [...prev, stamped]; }); setBuilderOpen(false); setEditingComposite(null); }, []); + const handleDuplicateComposite = useCallback( + (def: CompositeDefinition) => { + // Registering and persisting stay out of the setState updater: React runs + // updaters during render and twice under StrictMode. + const copy = duplicateComposite( + def, + composites.map((d) => d.name), + ); + registerComposite(copy, createCompositeComponent(copy)); + const stamped = saveComposite(copy); + setComposites((prev) => [...prev, stamped]); + }, + [composites], + ); + + const handleExportOneComposite = useCallback((def: CompositeDefinition) => { + downloadJSON(exportComposite(def), `${slugify(def.name, "-", "component")}.composite.json`); + }, []); + + const handleExportAllComposites = useCallback(() => { + downloadJSON(exportComposites(composites), "sandy-components.json"); + }, [composites]); + const handleImportComposites = useCallback((defs: CompositeDefinition[]) => { // Registering and persisting are side effects, so they belong in the handler rather // than inside the setState updater: React runs updaters during render and invokes // them twice under StrictMode, which would double-register and notify the composite // store mid-render. - for (const def of defs) { + const stamped = defs.map((def) => { registerComposite(def, createCompositeComponent(def)); - saveComposite(def); - } + return saveComposite(def); + }); setComposites((prev) => { const byId = new Map(prev.map((d) => [d.id, d])); - for (const def of defs) byId.set(def.id, def); + for (const def of stamped) byId.set(def.id, def); return [...byId.values()]; }); }, []); @@ -595,8 +629,7 @@ function SandboxContent() { onTokenEditorToggle={handleTokenEditorToggle} composites={composites} onCreateComponent={handleOpenBuilder} - onEditComposite={handleEditComposite} - onDeleteComposite={handleDeleteComposite} + onOpenLibrary={() => setLibraryOpen(true)} onImportComposites={handleImportComposites} generateAvailable={generateAvailable} onGenerate={handleGenerate} @@ -680,6 +713,7 @@ function SandboxContent() { s.component === editingComposite.id).length @@ -692,6 +726,26 @@ function SandboxContent() { }} /> )} + + {libraryOpen && ( + { + setLibraryOpen(false); + handleEditComposite(def); + }} + onDuplicate={handleDuplicateComposite} + onDelete={handleDeleteComposite} + onExportOne={handleExportOneComposite} + onExportAll={handleExportAllComposites} + onCreate={() => { + setLibraryOpen(false); + handleOpenBuilder(); + }} + onClose={() => setLibraryOpen(false)} + /> + )}
); } diff --git a/components/composite/primitive-renderers.tsx b/components/composite/primitive-renderers.tsx index 871ecf7..7275435 100644 --- a/components/composite/primitive-renderers.tsx +++ b/components/composite/primitive-renderers.tsx @@ -119,6 +119,10 @@ function ButtonRenderer({ props }: { props: Record }) { const baseStyle: React.CSSProperties = { display: "inline-block", + // Composites stack their nodes in a column flex, whose default align-items:stretch + // would blow an inline primitive out to the full width of the component. + alignSelf: "flex-start", + width: "fit-content", padding: "var(--sandy-spacing-sm, 8px) var(--sandy-spacing-md, 16px)", borderRadius: "var(--sandy-radius-md, 8px)", fontSize: "0.875rem", @@ -197,6 +201,9 @@ function BadgeRenderer({ props }: { props: Record }) { void; onClose: () => void; }; @@ -149,11 +151,13 @@ export function ComponentBuilder({ themeTokens, editingDefinition, sectionsUsing, + knownCategories, onSave, onClose, }: ComponentBuilderProps) { const [name, setName] = useState(editingDefinition?.name ?? ""); const [description, setDescription] = useState(editingDefinition?.description ?? ""); + const [category, setCategory] = useState(editingDefinition?.category ?? ""); const [nodes, setNodes] = useState(editingDefinition?.nodes ?? []); const [bindings, setBindings] = useState(editingDefinition?.propBindings ?? []); const [selectedNodeId, setSelectedNodeId] = useState(null); @@ -212,19 +216,21 @@ export function ComponentBuilder({ const handleSave = useCallback(() => { if (!name.trim() || nodes.length === 0) return; - const id = - editingDefinition?.id ?? - `custom_${slugify(name, "_", "custom_component")}_${Date.now().toString(36)}`; + // Reuse the existing id when editing: sections reference it, so a rename must not + // change the key. + const id = editingDefinition?.id ?? newCompositeId(name); const def: CompositeDefinition = { id, name: name.trim(), description: description.trim() || undefined, + category: category.trim() || undefined, nodes, propBindings: bindings, + createdAt: editingDefinition?.createdAt, version: "1.0", }; onSave(def); - }, [name, description, nodes, bindings, editingDefinition, onSave]); + }, [name, description, category, nodes, bindings, editingDefinition, onSave]); const canSave = name.trim().length > 0 && nodes.length > 0; @@ -315,6 +321,22 @@ export function ComponentBuilder({ className="h-7 flex-1 min-w-0 rounded bg-muted/30 border border-border/50 px-2 text-xs text-foreground outline-none focus:border-accent/50" /> +
+ + setCategory(e.target.value)} + placeholder="Uncategorized" + list="sandy-composite-categories" + className="h-7 flex-1 min-w-0 rounded bg-muted/30 border border-border/50 px-2 text-xs text-foreground outline-none focus:border-accent/50" + /> + + {knownCategories.map((c) => ( + +
{/* Main content */} diff --git a/components/sandbox/component-library.tsx b/components/sandbox/component-library.tsx new file mode 100644 index 0000000..2d58d1e --- /dev/null +++ b/components/sandbox/component-library.tsx @@ -0,0 +1,335 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { Copy, Download, Pencil, Plus, Puzzle, Trash2, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { CompositeRenderer } from "@/components/composite/composite-renderer"; +import { applyTheme } from "@/lib/theme/css-vars"; +import type { CompositeDefinition } from "@/lib/composite/types"; +import type { ThemeTokens } from "@/lib/theme/types"; + +type ComponentLibraryProps = { + composites: CompositeDefinition[]; + themeTokens: ThemeTokens; + onEdit: (def: CompositeDefinition) => void; + onDuplicate: (def: CompositeDefinition) => void; + onDelete: (id: string) => void; + onExportOne: (def: CompositeDefinition) => void; + onExportAll: () => void; + onCreate: () => void; + onClose: () => void; +}; + +const UNCATEGORIZED = "Uncategorized"; + +// Intl does the pluralisation and the "ago" phrasing, so there is no date library here +// and no hand-rolled plural table. +const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); +const DIVISIONS: [limit: number, unit: Intl.RelativeTimeFormatUnit][] = [ + [60, "second"], + [60, "minute"], + [24, "hour"], + [7, "day"], + [4.34524, "week"], + [12, "month"], + [Number.POSITIVE_INFINITY, "year"], +]; + +/** "-" when the component predates timestamps, "just now" under a minute, else "2 hours ago". */ +export function relativeTime(timestamp: number | undefined, now: number = Date.now()): string { + if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) return "-"; + const seconds = (timestamp - now) / 1000; + if (Math.abs(seconds) < 45) return "just now"; + let value = seconds; + for (const [limit, unit] of DIVISIONS) { + if (Math.abs(value) < limit) return rtf.format(Math.round(value), unit); + value /= limit; + } + return "-"; +} + +function categoryOf(def: CompositeDefinition): string { + return def.category?.trim() || UNCATEGORIZED; +} + +function PreviewErrorFallback({ error }: { error: unknown }) { + // react-error-boundary v6 types the error as unknown, so narrow before reading .message. + const message = error instanceof Error ? error.message : String(error); + return ( +
+ ! +

{message}

+
+ ); +} + +function LibraryCard({ + def, + themeTokens, + onEdit, + onDuplicate, + onDelete, + onExportOne, +}: { + def: CompositeDefinition; + themeTokens: ThemeTokens; + onEdit: (def: CompositeDefinition) => void; + onDuplicate: (def: CompositeDefinition) => void; + onDelete: (id: string) => void; + onExportOne: (def: CompositeDefinition) => void; +}) { + // One ref per card: the --sandy-* vars are scoped to this card's preview element, + // never to document.documentElement. + const previewRef = useRef(null); + + useEffect(() => { + if (previewRef.current) { + applyTheme(themeTokens, previewRef.current); + } + }, [themeTokens]); + + return ( +
+ {/* Live preview, isolated so one broken definition cannot take down the library */} +
+ + + +
+ +
+
+

{def.name}

+ + {categoryOf(def)} + +
+ + {def.description && ( +

{def.description}

+ )} + +

{`Updated ${relativeTime(def.updatedAt)}`}

+ +
+ + + + +
+
+
+ ); +} + +export function ComponentLibrary({ + composites, + themeTokens, + onEdit, + onDuplicate, + onDelete, + onExportOne, + onExportAll, + onCreate, + onClose, +}: ComponentLibraryProps) { + const [search, setSearch] = useState(""); + const [category, setCategory] = useState("All"); + + const categories = useMemo(() => { + const seen = new Set(composites.map(categoryOf)); + return ["All", ...[...seen].sort((a, b) => a.localeCompare(b))]; + }, [composites]); + + const visible = useMemo(() => { + const query = search.trim().toLowerCase(); + return composites + .filter((def) => category === "All" || categoryOf(def) === category) + .filter((def) => { + if (!query) return true; + return [def.name, def.description ?? "", categoryOf(def)].some((field) => + field.toLowerCase().includes(query), + ); + }) + .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + }, [composites, search, category]); + + const isEmptyLibrary = composites.length === 0; + + return ( + // A named region rather than a bare div: the library overlays the sandbox without + // unmounting it, so assistive tech (and tests) need a way to scope to this surface + // instead of matching the page still rendered underneath. +
+ {/* Header */} +
+

Component Library

+ + {composites.length} {composites.length === 1 ? "component" : "components"} + +
+ + + +
+ + {/* Search + category filters */} + {!isEmptyLibrary && ( +
+
+ + setSearch(e.target.value)} + placeholder="Name, description or category" + className="h-7 w-56 rounded border border-border/50 bg-muted/30 px-2 text-xs text-foreground outline-none focus:border-accent/50" + /> +
+
+ {categories.map((cat) => ( + + ))} +
+
+ )} + + {/* Grid */} +
+ {isEmptyLibrary ? ( +
+ +
+

No components yet

+

+ Build a component out of primitives and it shows up here. +

+
+ +
+ ) : visible.length === 0 ? ( +
+ +
+

No components match this search

+

Try a different term or clear the search.

+
+ +
+ ) : ( +
+ {visible.map((def) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/components/sandbox/toolbar.tsx b/components/sandbox/toolbar.tsx index b4335a4..f900a1f 100644 --- a/components/sandbox/toolbar.tsx +++ b/components/sandbox/toolbar.tsx @@ -19,11 +19,9 @@ import { Plus, FileText, Puzzle, - Pencil, - Trash2, + LibraryBig, Undo2, Redo2, - Download, Upload, } from "lucide-react"; import type { CompositeDefinition } from "@/lib/composite/types"; @@ -31,8 +29,7 @@ import { themePresets } from "@/lib/theme/presets"; import { getRegistryKeys, getRegistryItem } from "@/lib/registry"; import { subscribeToComposites, getCompositeVersion } from "@/lib/registry/composite-registry"; import { pageTemplates } from "@/lib/registry/templates"; -import { exportComposite, parseImportedComposites } from "@/lib/composite/io"; -import { downloadJSON } from "@/lib/export/json"; +import { parseImportedComposites } from "@/lib/composite/io"; import { ExportPanel } from "./export-panel"; import { GeneratePanel } from "./generate-panel"; import { SandyLogo } from "@/components/sandy-logo"; @@ -56,8 +53,7 @@ type ToolbarProps = { onTokenEditorToggle: () => void; composites?: CompositeDefinition[]; onCreateComponent?: () => void; - onEditComposite?: (def: CompositeDefinition) => void; - onDeleteComposite?: (id: string) => void; + onOpenLibrary?: () => void; onImportComposites?: (defs: CompositeDefinition[]) => void; generateAvailable?: boolean; onGenerate?: (prompt: string) => Promise; @@ -82,8 +78,7 @@ export const Toolbar = memo(function Toolbar({ onTokenEditorToggle, composites = [], onCreateComponent, - onEditComposite, - onDeleteComposite, + onOpenLibrary, onImportComposites, generateAvailable, onGenerate, @@ -216,50 +211,23 @@ export const Toolbar = memo(function Toolbar({ )} - {/* Custom components management */} - {composites.length > 0 && onEditComposite && onDeleteComposite && ( -
- {composites.map((def) => ( -
- {def.name} - - - -
- ))} -
+ {/* Library */} + {onOpenLibrary && ( + )} {onImportComposites && ( diff --git a/docs/superpowers/specs/2026-07-18-component-library-design.md b/docs/superpowers/specs/2026-07-18-component-library-design.md new file mode 100644 index 0000000..cd59adf --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-component-library-design.md @@ -0,0 +1,89 @@ +# Phase B - The component library surface + +_Date: 2026-07-18_ +_Status: implemented_ + +## Context + +Second of three phases on making Sandy usable as a daily design-system tool. The stated +blocker was "there is no real library concept" for composite components. + +[Phase A](./2026-07-18-composite-library-trust-design.md) fixed the correctness half: the +registry was invisible to React, so saved components rendered "Unknown component" on load +and went stale on edit. This phase builds the browsing surface on top of that. Phase C +(nested containers, so composites can express real structure) is separate. + +## The problem + +Saved composites had no home. They appeared as a flat row of pills in the toolbar - name +plus three icon buttons each - which does not survive more than a handful of components, +and shows no preview of what any of them actually look like. There was no search, no +grouping, no duplicate, no way to export the set, and no sense of what you had worked on +recently. A "library" you cannot look at is a list, not a library. + +## Design + +### Data model + +`CompositeDefinition` gains three optional fields: `category`, `createdAt`, `updatedAt`. + +All three are optional on purpose. Components saved before this change have none of them, +and the storage validator must keep accepting those - a library that silently drops your +older components to gain a sort order is a bad trade. Missing `updatedAt` sorts last and +renders as "-"; missing `category` groups under "Uncategorized". + +`saveComposite` stamps `updatedAt` on every write and preserves the original `createdAt` +across edits, then **returns the stamped definition** so callers can keep component state +in sync with what was actually written rather than with what they asked to write. + +### The panel + +`components/sandbox/component-library.tsx` is a full-screen overlay in the same visual +language as the component builder. It is purely presentational - it takes `composites` and +callbacks, and never touches storage or the registry itself, so the page stays the single +place that owns those side effects. + +Each card renders a **live preview** of the composite, themed with the active token set. +Theming is per card: each `LibraryCard` owns its own ref and applies `--sandy-*` variables +to its own preview element, never to `document.documentElement`. Each preview is wrapped in +its own error boundary, so one broken definition cannot take the library down with it. + +Search filters name, description and category together. Category chips filter alongside it. +Sort is most-recently-updated first. Empty states distinguish "you have nothing yet" from +"nothing matches this search", because those need different offers. + +The overlay is a **named region** (`aria-label="Component library"`) rather than a bare +div: it renders on top of the sandbox without unmounting it, so assistive technology and +tests both need a way to scope to this surface instead of matching the page underneath. + +### Toolbar + +The pill row is replaced by a single "Library" button carrying a count. Every action the +pills offered still exists, on the cards, plus duplicate and export-all. + +### Fixed along the way + +Composites stack their nodes in a column flex, whose default `align-items: stretch` was +blowing the `inline-block` badge and button primitives out to the component's full width. +Both now set `align-self: flex-start` and `width: fit-content`, which also holds inside +nested containers. This was visible in every composite preview and in exported output. + +## Testing + +- **Unit**: `identity.test.ts` covers id generation, copy naming against collisions, and + that duplication deep-clones rather than sharing structure. `component-library.test.tsx` + covers rendering, search, category filter, sort with missing timestamps, empty states, + each callback, and a jest-axe pass. +- **E2E** (`e2e/component-library.spec.ts`): listing order, search across all three fields, + duplicate surviving a reload, delete, edit opening the builder, and export-all's + filename. Queries are scoped to the library region because the sandbox is still rendered + behind the overlay. +- The Phase A e2e specs were updated to reach Edit through the library rather than the + removed pills. + +## Out of scope + +Nested containers and variants (Phase C). Delete confirmation - the pills deleted +immediately and this matches that; there is no dialog primitive to build it on yet. +Editing categories anywhere but the builder. Virtualization, which is unnecessary at tens +of components. Moving storage off `localStorage`. diff --git a/e2e/component-library.spec.ts b/e2e/component-library.spec.ts new file mode 100644 index 0000000..e3bfdf1 --- /dev/null +++ b/e2e/component-library.spec.ts @@ -0,0 +1,103 @@ +import { test, expect, type Locator } from "@playwright/test"; + +// The library is the browsing surface for saved composites: it replaced a cramped row of +// pills in the toolbar that did not scale past a handful of components. + +const composite = (id: string, name: string, category?: string, updatedAt?: number) => ({ + id, + name, + description: `${name} description`, + ...(category ? { category } : {}), + ...(updatedAt ? { updatedAt } : {}), + nodes: [{ id: "n1", type: "heading", props: { text: name, level: "h2", align: "left" } }], + propBindings: [ + { + propKey: "title", + label: "Title", + type: "string", + default: name, + required: true, + targetPath: [0, "props", "text"], + }, + ], + version: "1.0", +}); + +const saved = [ + composite("custom_price_tag_a", "Price Tag", "Commerce", 3000), + composite("custom_hero_b", "Hero Block", "Marketing", 9000), + composite("custom_loose_c", "Loose Widget"), +]; + +test.describe("component library", () => { + let library: Locator; + + test.beforeEach(async ({ page }) => { + // Seed only when absent: addInitScript runs on every navigation, so an + // unconditional write would wipe anything the test itself saved before a reload. + await page.addInitScript( + ([key, value]) => { + if (!window.localStorage.getItem(key)) window.localStorage.setItem(key, value); + }, + ["sandy-composites", JSON.stringify(saved)], + ); + await page.goto("/sandbox"); + await page.getByTitle("Open component library").click(); + library = page.getByRole("region", { name: "Component library" }); + }); + + test("lists every saved component, most recently updated first", async () => { + const names = await library.getByRole("heading", { level: 3 }).allInnerTexts(); + + expect(names).toEqual(["Hero Block", "Price Tag", "Loose Widget"]); + }); + + test("search filters by name, description and category", async () => { + const search = library.getByPlaceholder("Name, description or category"); + + await search.fill("hero"); + await expect(library.getByRole("heading", { level: 3 })).toHaveText(["Hero Block"]); + + await search.fill("commerce"); + await expect(library.getByRole("heading", { level: 3 })).toHaveText(["Price Tag"]); + + await search.fill("zzzz"); + await expect(library.getByRole("heading", { level: 3 })).toHaveCount(0); + }); + + test("duplicating adds a copy without touching the original", async ({ page }) => { + await library.getByTitle("Duplicate Price Tag").click(); + + await expect(page.getByRole("heading", { level: 3, name: "Price Tag copy" })).toBeVisible(); + await expect( + page.getByRole("heading", { level: 3, name: "Price Tag", exact: true }), + ).toBeVisible(); + + // The copy is a real saved component, so it survives a reload. + await page.reload(); + await page.getByTitle("Open component library").click(); + await expect(page.getByRole("heading", { level: 3, name: "Price Tag copy" })).toBeVisible(); + }); + + test("deleting removes the component from the library", async ({ page }) => { + await library.getByTitle("Delete Loose Widget").click(); + + await expect(page.getByRole("heading", { level: 3, name: "Loose Widget" })).toHaveCount(0); + await expect(library.getByRole("heading", { level: 3 })).toHaveCount(2); + }); + + test("editing a component from the library opens the builder for it", async ({ page }) => { + await library.getByTitle("Edit Price Tag").click(); + + await expect(page.getByText("Component Builder")).toBeVisible(); + await expect(page.locator('input[value="Price Tag"]')).toBeVisible(); + }); + + test("export all downloads a bundle containing every component", async ({ page }) => { + const downloadPromise = page.waitForEvent("download"); + await library.getByTitle("Export all components").click(); + const download = await downloadPromise; + + expect(download.suggestedFilename()).toBe("sandy-components.json"); + }); +}); diff --git a/e2e/composite-staleness.spec.ts b/e2e/composite-staleness.spec.ts index 06d947b..cb0d195 100644 --- a/e2e/composite-staleness.spec.ts +++ b/e2e/composite-staleness.spec.ts @@ -60,6 +60,7 @@ test.describe("saved composites resolve without a keystroke", () => { await expect(preview.getByText("Composite Rendered OK")).toBeVisible(); // Open the builder for the placed composite and add a new primitive to it. + await page.getByTitle("Open component library").click(); await page.getByTitle("Edit Price Tag").click(); await page.getByRole("button", { name: "Badge" }).click(); await page.getByRole("button", { name: "Save", exact: true }).click(); @@ -71,7 +72,9 @@ test.describe("saved composites resolve without a keystroke", () => { test("the Add Section picker shows the composite's name, not its raw id", async ({ page }) => { await page.goto("/sandbox"); - await expect(page.getByTitle("Edit Price Tag")).toBeVisible(); + // The library button carries a count, so it only reads 1 once the saved + // composite has been loaded and registered. + await expect(page.getByTitle("Open component library")).toContainText("1"); await page.getByText("Add Section").click(); await page.getByRole("option").last().scrollIntoViewIfNeeded(); @@ -91,6 +94,7 @@ test("editing a composite in use warns before breaking placed sections", async ( ); await page.goto(`/sandbox?s=${encodeState(pageUsingComposite)}`); + await page.getByTitle("Open component library").click(); await page.getByTitle("Edit Price Tag").click(); // No warning until the edit actually breaks something. diff --git a/lib/composite/identity.ts b/lib/composite/identity.ts new file mode 100644 index 0000000..e4a2b08 --- /dev/null +++ b/lib/composite/identity.ts @@ -0,0 +1,35 @@ +import { slugify } from "@/lib/utils"; +import type { CompositeDefinition } from "./types"; + +/** + * Registry keys for composites are opaque and permanent: sections reference them, so a + * rename must never change one. The readable prefix is a convenience for debugging only. + */ +export function newCompositeId(name: string): string { + return `custom_${slugify(name, "_", "custom_component")}_${Date.now().toString(36)}`; +} + +/** + * Clone a composite under a fresh identity. The copy must not reuse the id, or saving it + * would overwrite the original rather than sit beside it in the library. + */ +export function duplicateComposite( + def: CompositeDefinition, + existingNames: string[], +): CompositeDefinition { + const taken = new Set(existingNames); + let name = `${def.name} copy`; + let n = 2; + while (taken.has(name)) { + name = `${def.name} copy ${n}`; + n++; + } + + return { + ...structuredClone(def), + id: newCompositeId(name), + name, + createdAt: undefined, + updatedAt: undefined, + }; +} diff --git a/lib/composite/io.ts b/lib/composite/io.ts index ae87546..3061c4e 100644 --- a/lib/composite/io.ts +++ b/lib/composite/io.ts @@ -15,16 +15,21 @@ type CompositeBundle = { composites: CompositeDefinition[]; }; -/** Serialize a composite into a portable bundle string. */ -export function exportComposite(def: CompositeDefinition): string { +/** Serialize composites into a portable bundle string. */ +export function exportComposites(defs: CompositeDefinition[]): string { const bundle: CompositeBundle = { $schema: "sandy-composite", version: COMPOSITE_FILE_VERSION, - composites: [def], + composites: defs, }; return JSON.stringify(bundle, null, 2); } +/** Serialize a single composite into a portable bundle string. */ +export function exportComposite(def: CompositeDefinition): string { + return exportComposites([def]); +} + function isPrimitiveNode(n: unknown): n is PrimitiveNode { if (!n || typeof n !== "object") return false; const node = n as Record; diff --git a/lib/composite/storage.ts b/lib/composite/storage.ts index f0ee30d..bfdff6a 100644 --- a/lib/composite/storage.ts +++ b/lib/composite/storage.ts @@ -48,15 +48,26 @@ export function saveComposites(defs: CompositeDefinition[]): void { } } -export function saveComposite(def: CompositeDefinition): void { +/** Persists the definition and returns it with its timestamps stamped, so callers can + * keep component state in sync with what was actually written. */ +export function saveComposite(def: CompositeDefinition): CompositeDefinition { const { definitions } = loadComposites(); const idx = definitions.findIndex((d) => d.id === def.id); + const now = Date.now(); + // Preserve the original creation time across edits so "recently updated" and + // "when did I make this" stay distinct. + const stamped: CompositeDefinition = { + ...def, + createdAt: def.createdAt ?? definitions[idx]?.createdAt ?? now, + updatedAt: now, + }; if (idx >= 0) { - definitions[idx] = def; + definitions[idx] = stamped; } else { - definitions.push(def); + definitions.push(stamped); } saveComposites(definitions); + return stamped; } export function deleteComposite(id: string): void { diff --git a/lib/composite/types.ts b/lib/composite/types.ts index 30b8d5b..8ee910e 100644 --- a/lib/composite/types.ts +++ b/lib/composite/types.ts @@ -22,7 +22,16 @@ export type CompositeDefinition = { id: string; name: string; description?: string; + /** Free-text grouping shown in the library. Absent means "Uncategorized". */ + category?: string; nodes: PrimitiveNode[]; propBindings: PropBinding[]; + /** + * Epoch millis. Optional because components saved before the library existed have + * neither - the library treats a missing timestamp as oldest rather than dropping + * the component. + */ + createdAt?: number; + updatedAt?: number; version: "1.0"; }; From 9864ec7f4513564106a284c6b090e74f8da5a8ce Mon Sep 17 00:00:00 2001 From: DCCA Date: Sat, 18 Jul 2026 17:49:52 -0300 Subject: [PATCH 2/2] fix: make the library and builder overlays actually modal A five-lens adversarial review found the library overlay was not modal in any real sense. It was a fixed inset-0 div painting over the sandbox: focus stayed on the toolbar button behind it, Tab then walked the invisible toolbar and Monaco editor with no visible focus ring, Escape did nothing, and a screen reader read the entire covered page as if it were on screen. The component builder had the identical defect, so this fixes both through one shared ModalOverlay rather than patching the new surface only. It uses a native opened with showModal(), which provides focus movement, focus trapping, background inertness and Escape-to-close from the browser - no hand-rolled focus trap to keep correct. jsdom implements neither showModal nor close, so it degrades to a non-modal open dialog there and the real modality is asserted in Playwright, where two new tests confirm focus lands inside the dialog, Escape closes it, and controls behind it can no longer be focused. Other findings from the same review: - Deleting the last component in a category left the selected category filtering everything out with no chip showing it, so the grid read 'no components match this search' with an empty search box. A category that no longer exists is now treated as no filter, making the stale state unreachable rather than merely recoverable. - Below the xl breakpoint the toolbar Library button renders only its count, so its accessible name was the number alone. It carries an explicit aria-label now. - The result count is a live region, so typing in the search field announces how many components matched instead of forcing the user to leave the input and browse the grid. --- components/sandbox/component-builder.tsx | 5 +- components/sandbox/component-library.tsx | 31 ++++++---- components/sandbox/modal-overlay.tsx | 62 +++++++++++++++++++ components/sandbox/toolbar.tsx | 3 + .../2026-07-18-component-library-design.md | 15 ++++- e2e/component-library.spec.ts | 28 ++++++++- 6 files changed, 125 insertions(+), 19 deletions(-) create mode 100644 components/sandbox/modal-overlay.tsx diff --git a/components/sandbox/component-builder.tsx b/components/sandbox/component-builder.tsx index 76fafa6..45be22e 100644 --- a/components/sandbox/component-builder.tsx +++ b/components/sandbox/component-builder.tsx @@ -10,6 +10,7 @@ import { NodeRenderer } from "@/components/composite/primitive-renderers"; import { getDefaultProps, primitiveSchemas } from "@/lib/composite/primitives"; import { applyTheme } from "@/lib/theme/css-vars"; import { newCompositeId } from "@/lib/composite/identity"; +import { ModalOverlay } from "./modal-overlay"; import { Select, SelectContent, @@ -244,7 +245,7 @@ export function ComponentBuilder({ const breakingEdit = sectionsUsing > 0 && bindingImpact !== null && isBreaking(bindingImpact); return ( -
+ {/* Header */}
Component Builder @@ -398,6 +399,6 @@ export function ComponentBuilder({
- + ); } diff --git a/components/sandbox/component-library.tsx b/components/sandbox/component-library.tsx index 2d58d1e..b75eee5 100644 --- a/components/sandbox/component-library.tsx +++ b/components/sandbox/component-library.tsx @@ -6,6 +6,7 @@ import { Copy, Download, Pencil, Plus, Puzzle, Trash2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { CompositeRenderer } from "@/components/composite/composite-renderer"; import { applyTheme } from "@/lib/theme/css-vars"; +import { ModalOverlay } from "./modal-overlay"; import type { CompositeDefinition } from "@/lib/composite/types"; import type { ThemeTokens } from "@/lib/theme/types"; @@ -182,10 +183,16 @@ export function ComponentLibrary({ return ["All", ...[...seen].sort((a, b) => a.localeCompare(b))]; }, [composites]); + // Deleting the last component in a category removes its chip, but the selected + // category is plain state and would keep filtering everything out - leaving an empty + // grid with no visible filter to explain it. Treat a category that no longer exists + // as no filter, so the stale state is unreachable rather than merely recoverable. + const activeCategory = categories.includes(category) ? category : "All"; + const visible = useMemo(() => { const query = search.trim().toLowerCase(); return composites - .filter((def) => category === "All" || categoryOf(def) === category) + .filter((def) => activeCategory === "All" || categoryOf(def) === activeCategory) .filter((def) => { if (!query) return true; return [def.name, def.description ?? "", categoryOf(def)].some((field) => @@ -193,23 +200,21 @@ export function ComponentLibrary({ ); }) .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); - }, [composites, search, category]); + }, [composites, search, activeCategory]); const isEmptyLibrary = composites.length === 0; return ( - // A named region rather than a bare div: the library overlays the sandbox without - // unmounting it, so assistive tech (and tests) need a way to scope to this surface - // instead of matching the page still rendered underneath. -
+ {/* Header */}

Component Library

- - {composites.length} {composites.length === 1 ? "component" : "components"} + {/* Live so that typing in the search field announces the result count, instead + of the user having to leave the input and browse the grid to find out. */} + + {visible.length === composites.length + ? `${composites.length} ${composites.length === 1 ? "component" : "components"}` + : `${visible.length} of ${composites.length} components`}
)}
-
+ ); } diff --git a/components/sandbox/modal-overlay.tsx b/components/sandbox/modal-overlay.tsx new file mode 100644 index 0000000..a6d50c5 --- /dev/null +++ b/components/sandbox/modal-overlay.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +type ModalOverlayProps = { + /** Accessible name for the dialog. */ + label: string; + onClose: () => void; + children: React.ReactNode; +}; + +/** + * Full-screen modal surface for the builder and the library. + * + * Both used to be a plain `fixed inset-0` div, which paints over the sandbox without + * actually being modal: focus stayed on the toolbar button behind it, Tab walked the + * invisible editor and toolbar underneath, Escape did nothing, and a screen reader read + * the whole covered page as if it were still on screen. + * + * A native opened with showModal() gets all of that from the browser - focus + * moves in, focus is trapped, the rest of the document goes inert, and Escape closes - + * so there is no hand-rolled focus trap to keep correct. + */ +export function ModalOverlay({ label, onClose, children }: ModalOverlayProps) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + if (typeof el.showModal === "function") { + if (!el.open) el.showModal(); + } else { + // jsdom has no showModal, and a without `open` is display:none, so unit + // tests would render nothing. Degrade to a non-modal open dialog there; the real + // modality is verified in the Playwright specs. + el.open = true; + } + + return () => { + if (!el.open) return; + // Same jsdom gap as showModal: close() is missing there too. + if (typeof el.close === "function") el.close(); + else el.open = false; + }; + }, []); + + return ( + { + e.preventDefault(); + onClose(); + }} + className="fixed inset-0 z-50 m-0 h-full max-h-none w-full max-w-none flex-col bg-background/95 p-0 text-foreground backdrop-blur-md open:flex" + > + {children} + + ); +} diff --git a/components/sandbox/toolbar.tsx b/components/sandbox/toolbar.tsx index f900a1f..3fe20a7 100644 --- a/components/sandbox/toolbar.tsx +++ b/components/sandbox/toolbar.tsx @@ -219,6 +219,9 @@ export const Toolbar = memo(function Toolbar({ className="h-7 px-2 gap-1.5 text-xs" onClick={onOpenLibrary} title="Open component library" + // Below xl the label is hidden and only the count renders, which would make + // the accessible name just "3". An explicit label wins over content. + aria-label="Open component library" > Library diff --git a/docs/superpowers/specs/2026-07-18-component-library-design.md b/docs/superpowers/specs/2026-07-18-component-library-design.md index cd59adf..ceba6a2 100644 --- a/docs/superpowers/specs/2026-07-18-component-library-design.md +++ b/docs/superpowers/specs/2026-07-18-component-library-design.md @@ -52,9 +52,18 @@ Search filters name, description and category together. Category chips filter al Sort is most-recently-updated first. Empty states distinguish "you have nothing yet" from "nothing matches this search", because those need different offers. -The overlay is a **named region** (`aria-label="Component library"`) rather than a bare -div: it renders on top of the sandbox without unmounting it, so assistive technology and -tests both need a way to scope to this surface instead of matching the page underneath. +The overlay is a **real modal**, via `components/sandbox/modal-overlay.tsx`. A multi-lens +review caught that the original `fixed inset-0` div painted over the sandbox without being +modal at all: focus stayed on the toolbar button behind it, Tab walked the invisible editor +and toolbar underneath with no visible focus ring, Escape did nothing, and a screen reader +read the whole covered page as if it were still on screen. The component builder had the +same defect, so the fix is shared by both rather than applied twice. + +It uses a native `` opened with `showModal()`, which gets focus movement, focus +trapping, background inertness and Escape-to-close from the browser - there is no +hand-rolled focus trap to keep correct. jsdom implements neither `showModal` nor `close`, +so the component degrades to a non-modal open dialog there and the real modality is +asserted in Playwright instead. ### Toolbar diff --git a/e2e/component-library.spec.ts b/e2e/component-library.spec.ts index e3bfdf1..3030f5a 100644 --- a/e2e/component-library.spec.ts +++ b/e2e/component-library.spec.ts @@ -43,7 +43,7 @@ test.describe("component library", () => { ); await page.goto("/sandbox"); await page.getByTitle("Open component library").click(); - library = page.getByRole("region", { name: "Component library" }); + library = page.getByRole("dialog", { name: "Component library" }); }); test("lists every saved component, most recently updated first", async () => { @@ -100,4 +100,30 @@ test.describe("component library", () => { expect(download.suggestedFilename()).toBe("sandy-components.json"); }); + + test("is a real modal: Escape closes it and focus moves inside", async ({ page }) => { + await expect(library).toBeVisible(); + + // Focus must land inside the dialog, not stay on the covered toolbar button. + const focusedInside = await page.evaluate(() => { + const dialog = document.querySelector("dialog[aria-label='Component library']"); + return !!dialog && dialog.contains(document.activeElement); + }); + expect(focusedInside).toBe(true); + + await page.keyboard.press("Escape"); + await expect(library).toBeHidden(); + }); + + test("makes the sandbox behind it inert", async ({ page }) => { + // showModal() puts the dialog in the top layer, so controls underneath stop being + // reachable - previously Tab walked the invisible toolbar and Monaco editor. + const toolbarReachable = await page.evaluate(() => { + const btn = document.querySelector("button[aria-label='Open component library']"); + if (!(btn instanceof HTMLElement)) return "missing"; + btn.focus(); + return document.activeElement === btn; + }); + expect(toolbarReachable).toBe(false); + }); });