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"
/>
+
+ Category
+ 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 (
+
+ );
+}
+
+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)}`}
+
+
+
onEdit(def)}
+ title={`Edit ${def.name}`}
+ >
+
+
+
onDuplicate(def)}
+ title={`Duplicate ${def.name}`}
+ >
+
+
+
onExportOne(def)}
+ title={`Export ${def.name}`}
+ >
+
+
+
onDelete(def.id)}
+ title={`Delete ${def.name}`}
+ >
+
+
+
+
+
+ );
+}
+
+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`}
+
+
+
+
+ Export all
+
+
+
+ New component
+
+
+
+
+
+
+ {/* Search + category filters */}
+ {!isEmptyLibrary && (
+
+
+
+ Search
+
+ 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) => (
+ setCategory(cat)}
+ className={`rounded-full px-2.5 py-1 text-[11px] transition-colors ${
+ category === cat
+ ? "bg-secondary text-secondary-foreground"
+ : "bg-muted/30 text-muted-foreground hover:bg-muted/60"
+ }`}
+ >
+ {cat}
+
+ ))}
+
+
+ )}
+
+ {/* Grid */}
+
+ {isEmptyLibrary ? (
+
+
+
+
No components yet
+
+ Build a component out of primitives and it shows up here.
+
+
+
+ Create your first component
+
+
+ ) : visible.length === 0 ? (
+
+
+
+
No components match this search
+
Try a different term or clear the search.
+
+
{
+ setSearch("");
+ setCategory("All");
+ }}
+ >
+ Clear 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}
-
onEditComposite(def)}
- title={`Edit ${def.name}`}
- >
-
-
-
- downloadJSON(
- exportComposite(def),
- `${def.name.replace(/\s+/g, "-").toLowerCase()}.composite.json`,
- )
- }
- title={`Export ${def.name}`}
- >
-
-
-
onDeleteComposite(def.id)}
- title={`Delete ${def.name}`}
- >
-
-
-
- ))}
-
+ {/* Library */}
+ {onOpenLibrary && (
+
+
+ Library
+ {composites.length > 0 && (
+
+ {composites.length}
+
+ )}
+
)}
{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";
};