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 +152,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 +217,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; @@ -238,7 +245,7 @@ export function ComponentBuilder({ const breakingEdit = sectionsUsing > 0 && bindingImpact !== null && isBreaking(bindingImpact); return ( -
+ {/* Header */}
Component Builder @@ -315,6 +322,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 */} @@ -376,6 +399,6 @@ export function ComponentBuilder({ - + ); } diff --git a/components/sandbox/component-library.tsx b/components/sandbox/component-library.tsx new file mode 100644 index 0000000..b75eee5 --- /dev/null +++ b/components/sandbox/component-library.tsx @@ -0,0 +1,340 @@ +"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 { ModalOverlay } from "./modal-overlay"; +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]); + + // 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) => activeCategory === "All" || categoryOf(def) === activeCategory) + .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, activeCategory]); + + const isEmptyLibrary = composites.length === 0; + + return ( + + {/* Header */} +
+

Component Library

+ {/* 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`} + +
+ + + +
+ + {/* 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/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 b4335a4..3fe20a7 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,26 @@ 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..ceba6a2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-component-library-design.md @@ -0,0 +1,98 @@ +# 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 **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 + +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..3030f5a --- /dev/null +++ b/e2e/component-library.spec.ts @@ -0,0 +1,129 @@ +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("dialog", { 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"); + }); + + 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); + }); +}); 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"; };