From 70b255660483bcc30bbcae734d54063a35c7cd2e Mon Sep 17 00:00:00 2001 From: DCCA Date: Sat, 18 Jul 2026 18:25:41 -0300 Subject: [PATCH] fix: guard the two destructive paths dogfooding exposed Driving the app by hand instead of by test found two ways to lose work with no warning. Deleting a component that was placed on the page silently broke the page. One click in the library, no confirmation, and the section rendered 'Unknown component'. That was inconsistent with our own safety story - we warn elaborately when *editing* a component in use, but said nothing about the far more destructive delete. Escape discarding unsaved builder work was a regression introduced by the accessibility fix. Before the overlays became real modals, Escape did nothing and the only exit was a deliberate click on the X; making them modal turned discarding into a single reflexive keystroke. Both now route through a ConfirmDialog built on ModalOverlay, which gives it the same browser-provided modality. The delete prompt names how many sections it will break and says the action cannot be undone; when nothing uses the component it suggests exporting first instead. The discard prompt only appears when the builder is genuinely dirty, so closing an untouched builder still takes one keystroke. Cancel takes focus in both, so the safe outcome is the one you get by reflex. --- .keikaku/status.md | 18 +- __tests__/sandbox/component-library.test.tsx | 29 ++ app/sandbox/page.tsx | 10 + components/sandbox/component-builder.tsx | 330 +++++++++++-------- components/sandbox/component-library.tsx | 285 +++++++++------- components/sandbox/confirm-dialog.tsx | 55 ++++ components/sandbox/modal-overlay.tsx | 18 +- e2e/component-library.spec.ts | 5 + e2e/destructive-guards.spec.ts | 125 +++++++ 9 files changed, 597 insertions(+), 278 deletions(-) create mode 100644 components/sandbox/confirm-dialog.tsx create mode 100644 e2e/destructive-guards.spec.ts diff --git a/.keikaku/status.md b/.keikaku/status.md index a0744ff..91fb0d3 100644 --- a/.keikaku/status.md +++ b/.keikaku/status.md @@ -64,14 +64,11 @@ in, the stored login is used and no token is needed.) Composite library A–C done. Open threads, in rough priority order: -1. **Delete confirmation** in the library. Deletion is immediate and irreversible, matching - the old toolbar pills, but the library makes deleting far easier to reach. Needs a dialog - primitive - `components/ui/` has none, and `ModalOverlay` is now a reasonable base for one. -2. **Dragging nodes between parents** in the builder. Reordering currently stays within a +1. **Dragging nodes between parents** in the builder. Reordering currently stays within a node's own sibling list. -3. **Wrapping existing nodes in a container**, so structure can be introduced after the fact +2. **Wrapping existing nodes in a container**, so structure can be introduced after the fact instead of only planned up front. -4. Gen-UI: candidate follow-ups (from `docs/generative-ui.md` §6): quality evals + telemetry +3. Gen-UI: candidate follow-ups (from `docs/generative-ui.md` §6): quality evals + telemetry on validation-failure rate (Phase 2), streaming preview via `streamObject`/SpecStream (Phase 3), theming-aware generation (Phase 4). Smaller polish: wire an `AbortController`/cancel so a slow generation can be cancelled instead of waiting out the 120s timeout (noted in review). @@ -125,6 +122,15 @@ limitation. Tree manipulation moved into pure helpers in `lib/composite/tree.ts` - **Notices are not validation errors.** A new `notice` type carries "N saved components could not be loaded" without suppressing the "Valid" indicator. +**Found by dogfooding, then fixed (#30):** driving the app by hand rather than by test +exposed two destructive paths with no guard. Deleting a component that was placed on the +page silently broke the page ("Unknown component", one click, no confirmation) - inconsistent +with the elaborate warning built for _editing_ a component in use. And Escape, newly wired +when the overlays became real modals, discarded unsaved builder work in a single reflexive +keystroke - a regression introduced by the accessibility fix itself. Both now go through a +`ConfirmDialog` built on `ModalOverlay`; the delete prompt names how many sections it will +break, and the discard prompt only appears when the builder is actually dirty. + **Worth remembering:** a subagent dispatched **read-only** for review edited source anyway - it injected `id: def.id` into `duplicateComposite` (which would make duplicating overwrite the original) and left probe files behind. The test suite caught the mutation immediately, diff --git a/__tests__/sandbox/component-library.test.tsx b/__tests__/sandbox/component-library.test.tsx index 375b842..30cf1a7 100644 --- a/__tests__/sandbox/component-library.test.tsx +++ b/__tests__/sandbox/component-library.test.tsx @@ -158,10 +158,39 @@ describe("ComponentLibrary", () => { fireEvent.click(screen.getByTitle("Export Alpha Card")); expect(props.onExportOne).toHaveBeenCalledWith(alpha); + // Delete is guarded: the card button only opens a confirmation. fireEvent.click(screen.getByTitle("Delete Alpha Card")); + expect(props.onDelete).not.toHaveBeenCalled(); + }); + + it("deletes only after the confirmation is accepted", () => { + const { props } = renderLibrary(); + + fireEvent.click(screen.getByTitle("Delete Alpha Card")); + expect(screen.getByText(/Delete "Alpha Card"\?/)).toBeDefined(); + + fireEvent.click(screen.getByRole("button", { name: "Delete" })); expect(props.onDelete).toHaveBeenCalledWith("alpha"); }); + it("does not delete when the confirmation is cancelled", () => { + const { props } = renderLibrary(); + + fireEvent.click(screen.getByTitle("Delete Alpha Card")); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(props.onDelete).not.toHaveBeenCalled(); + expect(screen.queryByText(/Delete "Alpha Card"\?/)).toBeNull(); + }); + + it("warns when the component is used by sections on the current page", () => { + renderLibrary({ usageById: { alpha: 2 } }); + + fireEvent.click(screen.getByTitle("Delete Alpha Card")); + + expect(screen.getByText(/2 sections on the current page/)).toBeDefined(); + }); + it("fires the header actions", () => { const { props } = renderLibrary(); diff --git a/app/sandbox/page.tsx b/app/sandbox/page.tsx index a144b05..2cbf998 100644 --- a/app/sandbox/page.tsx +++ b/app/sandbox/page.tsx @@ -211,6 +211,15 @@ function SandboxContent() { }, [parsedSections, parseFailed]); const sectionsForUsage = parseFailed ? lastGoodSections.current : parsedSections; + // Per-component usage on the current page, so deleting one can say what it will break. + const compositeUsage = useMemo(() => { + const counts: Record = {}; + for (const section of sectionsForUsage) { + counts[section.component] = (counts[section.component] ?? 0) + 1; + } + return counts; + }, [sectionsForUsage]); + const knownCategories = useMemo( () => [...new Set(composites.map((d) => d.category).filter((c): c is string => !!c))].sort(), [composites], @@ -739,6 +748,7 @@ function SandboxContent() { onDelete={handleDeleteComposite} onExportOne={handleExportOneComposite} onExportAll={handleExportAllComposites} + usageById={compositeUsage} onCreate={() => { setLibraryOpen(false); handleOpenBuilder(); diff --git a/components/sandbox/component-builder.tsx b/components/sandbox/component-builder.tsx index 22500ae..b111a14 100644 --- a/components/sandbox/component-builder.tsx +++ b/components/sandbox/component-builder.tsx @@ -11,6 +11,7 @@ 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 { ConfirmDialog } from "./confirm-dialog"; import { findNode, insertNode, @@ -171,6 +172,7 @@ export function ComponentBuilder({ const [nodes, setNodes] = useState(editingDefinition?.nodes ?? []); const [bindings, setBindings] = useState(editingDefinition?.propBindings ?? []); const [selectedNodeId, setSelectedNodeId] = useState(null); + const [confirmDiscard, setConfirmDiscard] = useState(false); const previewRef = useRef(null); useEffect(() => { @@ -245,6 +247,20 @@ export function ComponentBuilder({ const canSave = name.trim().length > 0 && nodes.length > 0; + // Escape now closes this surface (it is a real modal), which makes discarding work a + // single reflexive keystroke. Only prompt when there is something to lose. + const isDirty = + name !== (editingDefinition?.name ?? "") || + description !== (editingDefinition?.description ?? "") || + category !== (editingDefinition?.category ?? "") || + JSON.stringify(nodes) !== JSON.stringify(editingDefinition?.nodes ?? []) || + JSON.stringify(bindings) !== JSON.stringify(editingDefinition?.propBindings ?? []); + + const requestClose = useCallback(() => { + if (isDirty) setConfirmDiscard(true); + else onClose(); + }, [isDirty, onClose]); + // Editing a component that's already placed: show what this edit does to those // sections. Dropped props lose their stored value silently; newly required props // make the existing sections fail validation outright. @@ -255,160 +271,186 @@ export function ComponentBuilder({ const breakingEdit = sectionsUsing > 0 && bindingImpact !== null && isBreaking(bindingImpact); return ( - - {/* Header */} -
- Component Builder -
- - -
+ <> + + {/* Header */} +
+ Component Builder +
+ + +
- {/* In-use warning */} - {breakingEdit && bindingImpact && ( -
- -
- - {sectionsUsing} section{sectionsUsing === 1 ? "" : "s"} on this page use - {sectionsUsing === 1 ? "s" : ""} this component. - {" "} - {bindingImpact.droppedProps.length > 0 && ( - <> - Unbinding {bindingImpact.droppedProps.join(", ")}{" "} - stops {bindingImpact.droppedProps.length === 1 ? "its" : "their"} saved value from - being applied (it stays in your JSON).{" "} - - )} - {bindingImpact.newRequiredProps.length > 0 && ( - <> - Requiring{" "} - {bindingImpact.newRequiredProps.join(", ")} makes{" "} - {sectionsUsing === 1 ? "it" : "them"} fail validation until filled in.{" "} - - )} - {bindingImpact.retypedProps.length > 0 && ( - <> - Changing {bindingImpact.retypedProps.join(", ")}{" "} - may reject {bindingImpact.retypedProps.length === 1 ? "its" : "their"} saved value. - - )} + {/* In-use warning */} + {breakingEdit && bindingImpact && ( +
+ +
+ + {sectionsUsing} section{sectionsUsing === 1 ? "" : "s"} on this page use + {sectionsUsing === 1 ? "s" : ""} this component. + {" "} + {bindingImpact.droppedProps.length > 0 && ( + <> + Unbinding{" "} + {bindingImpact.droppedProps.join(", ")} stops{" "} + {bindingImpact.droppedProps.length === 1 ? "its" : "their"} saved value from being + applied (it stays in your JSON).{" "} + + )} + {bindingImpact.newRequiredProps.length > 0 && ( + <> + Requiring{" "} + {bindingImpact.newRequiredProps.join(", ")}{" "} + makes {sectionsUsing === 1 ? "it" : "them"} fail validation until filled in.{" "} + + )} + {bindingImpact.retypedProps.length > 0 && ( + <> + Changing{" "} + {bindingImpact.retypedProps.join(", ")} may + reject {bindingImpact.retypedProps.length === 1 ? "its" : "their"} saved value. + + )} +
-
- )} + )} - {/* Name / Description */} -
-
- - setName(e.target.value)} - placeholder="My Component" - 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" - /> -
-
- - setDescription(e.target.value)} - placeholder="Optional description" - 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) => ( - + {/* Name / Description */} +
+
+ + setName(e.target.value)} + placeholder="My Component" + 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" + /> +
+
+ + setDescription(e.target.value)} + placeholder="Optional description" + 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 */} -
- {/* Left: Palette + Node Tree + Node Props */} -
- - - {selectedNode && } -
+ {/* Main content */} +
+ {/* Left: Palette + Node Tree + Node Props */} +
+ + + {selectedNode && ( + + )} +
- {/* Center: Live Preview */} -
-
-
-
- - - -
-
- {nodes.length === 0 ? ( -
- Add primitives to build your component -
- ) : ( -
- {nodes.map((node) => ( - - ))} -
- )} + {/* Center: Live Preview */} +
+
+
+
+ + + +
+
+ {nodes.length === 0 ? ( +
+ Add primitives to build your component +
+ ) : ( +
+ {nodes.map((node) => ( + + ))} +
+ )} +
-
- {/* Right: Bindings */} -
- + {/* Right: Bindings */} +
+ +
-
- + + + {confirmDiscard && ( + { + setConfirmDiscard(false); + onClose(); + }} + onCancel={() => setConfirmDiscard(false)} + /> + )} + ); } diff --git a/components/sandbox/component-library.tsx b/components/sandbox/component-library.tsx index b75eee5..16c5f1b 100644 --- a/components/sandbox/component-library.tsx +++ b/components/sandbox/component-library.tsx @@ -7,6 +7,7 @@ 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 { ConfirmDialog } from "./confirm-dialog"; import type { CompositeDefinition } from "@/lib/composite/types"; import type { ThemeTokens } from "@/lib/theme/types"; @@ -20,6 +21,8 @@ type ComponentLibraryProps = { onExportAll: () => void; onCreate: () => void; onClose: () => void; + /** How many sections on the current page use each component, keyed by id. */ + usageById?: Record; }; const UNCATEGORIZED = "Uncategorized"; @@ -77,7 +80,7 @@ function LibraryCard({ themeTokens: ThemeTokens; onEdit: (def: CompositeDefinition) => void; onDuplicate: (def: CompositeDefinition) => void; - onDelete: (id: string) => void; + onDelete: (def: CompositeDefinition) => void; onExportOne: (def: CompositeDefinition) => void; }) { // One ref per card: the --sandy-* vars are scoped to this card's preview element, @@ -153,7 +156,7 @@ function LibraryCard({ variant="ghost" size="sm" className="h-6 w-6 p-0 text-red-400 hover:text-red-300" - onClick={() => onDelete(def.id)} + onClick={() => onDelete(def)} title={`Delete ${def.name}`} > @@ -174,8 +177,10 @@ export function ComponentLibrary({ onExportAll, onCreate, onClose, + usageById = {}, }: ComponentLibraryProps) { const [search, setSearch] = useState(""); + const [pendingDelete, setPendingDelete] = useState(null); const [category, setCategory] = useState("All"); const categories = useMemo(() => { @@ -205,136 +210,164 @@ export function ComponentLibrary({ const isEmptyLibrary = composites.length === 0; return ( - - {/* Header */} -
-

Component Library

- {/* Live so that typing in the search field announces the result count, instead + <> + + {/* 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) => ( - - ))} -
+ + {visible.length === composites.length + ? `${composites.length} ${composites.length === 1 ? "component" : "components"}` + : `${visible.length} of ${composites.length} components`} + +
+ + +
- )} - {/* Grid */} -
- {isEmptyLibrary ? ( -
- -
-

No components yet

-

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

+ {/* 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" + />
- -
- ) : visible.length === 0 ? ( -
- -
-

No components match this search

-

Try a different term or clear the search.

+
+ {categories.map((cat) => ( + + ))}
- -
- ) : ( -
- {visible.map((def) => ( - - ))}
)} -
- + + {/* 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) => ( + + ))} +
+ )} +
+ + + {pendingDelete && ( + 0 ? ( + <> + {usageById[pendingDelete.id]} section + {usageById[pendingDelete.id] === 1 ? "" : "s"} on the current page use + {usageById[pendingDelete.id] === 1 ? "s" : ""} this component. Deleting it will + leave {usageById[pendingDelete.id] === 1 ? "that section" : "those sections"} unable + to render. This cannot be undone. + + ) : ( + <>This cannot be undone. Export it first if you want to keep a copy. + ) + } + confirmLabel="Delete" + destructive + onConfirm={() => { + onDelete(pendingDelete.id); + setPendingDelete(null); + }} + onCancel={() => setPendingDelete(null)} + /> + )} + ); } diff --git a/components/sandbox/confirm-dialog.tsx b/components/sandbox/confirm-dialog.tsx new file mode 100644 index 0000000..749c344 --- /dev/null +++ b/components/sandbox/confirm-dialog.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { ModalOverlay } from "./modal-overlay"; + +type ConfirmDialogProps = { + title: string; + /** What will happen, stated concretely. Skip it when the title already says everything. */ + body?: React.ReactNode; + confirmLabel: string; + destructive?: boolean; + onConfirm: () => void; + onCancel: () => void; +}; + +/** + * A short prompt in front of an irreversible action. Cancel is the default focus and + * Escape cancels, so the safe outcome is the one you get by reflex. + */ +export function ConfirmDialog({ + title, + body, + confirmLabel, + destructive = false, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + return ( + +
+

{title}

+ {body &&
{body}
} +
+ + +
+
+
+ ); +} diff --git a/components/sandbox/modal-overlay.tsx b/components/sandbox/modal-overlay.tsx index a6d50c5..2e1a3d0 100644 --- a/components/sandbox/modal-overlay.tsx +++ b/components/sandbox/modal-overlay.tsx @@ -5,10 +5,19 @@ import { useEffect, useRef } from "react"; type ModalOverlayProps = { /** Accessible name for the dialog. */ label: string; + /** Fullscreen for working surfaces, centered for a short prompt. */ + variant?: "fullscreen" | "centered"; onClose: () => void; children: React.ReactNode; }; +const VARIANTS = { + fullscreen: + "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", + centered: + "fixed inset-0 z-50 m-auto h-fit w-full max-w-sm flex-col rounded-lg border border-border/50 bg-background p-0 text-foreground shadow-2xl shadow-black/40 open:flex", +} as const; + /** * Full-screen modal surface for the builder and the library. * @@ -21,7 +30,12 @@ type ModalOverlayProps = { * 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) { +export function ModalOverlay({ + label, + variant = "fullscreen", + onClose, + children, +}: ModalOverlayProps) { const ref = useRef(null); useEffect(() => { @@ -54,7 +68,7 @@ export function ModalOverlay({ label, onClose, children }: ModalOverlayProps) { 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" + className={VARIANTS[variant]} > {children} diff --git a/e2e/component-library.spec.ts b/e2e/component-library.spec.ts index 3030f5a..ff05e19 100644 --- a/e2e/component-library.spec.ts +++ b/e2e/component-library.spec.ts @@ -81,6 +81,11 @@ test.describe("component library", () => { test("deleting removes the component from the library", async ({ page }) => { await library.getByTitle("Delete Loose Widget").click(); + // Deletion is guarded by a confirmation. + await page + .getByRole("dialog", { name: /Delete "Loose Widget"/ }) + .getByRole("button", { name: "Delete", exact: true }) + .click(); await expect(page.getByRole("heading", { level: 3, name: "Loose Widget" })).toHaveCount(0); await expect(library.getByRole("heading", { level: 3 })).toHaveCount(2); diff --git a/e2e/destructive-guards.spec.ts b/e2e/destructive-guards.spec.ts new file mode 100644 index 0000000..f5a3ff1 --- /dev/null +++ b/e2e/destructive-guards.spec.ts @@ -0,0 +1,125 @@ +import { test, expect } from "@playwright/test"; + +// Both guards came out of an exploratory pass, not from a test plan: +// - deleting a component that was placed on the page silently broke the page +// - Escape (newly wired when the overlays became real modals) discarded builder work + +function encodeState(page: unknown): string { + return Buffer.from(encodeURIComponent(JSON.stringify(page))).toString("base64"); +} + +const composite = { + id: "custom_placed_a", + name: "Placed Card", + description: "Used on the page", + nodes: [{ id: "n1", type: "heading", props: { text: "Placed", level: "h3", align: "left" } }], + propBindings: [ + { + propKey: "title", + label: "Title", + type: "string", + default: "Placed", + required: true, + targetPath: [0, "props", "text"], + }, + ], + version: "1.0", +}; + +const pageUsingIt = { + version: "2.0", + theme: { brand: "default", mode: "light" }, + sections: [{ id: "sec_1", component: composite.id, props: { title: "I am placed" } }], +}; + +test.describe("deleting a component in use", () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript( + ([key, value]) => { + if (!window.localStorage.getItem(key)) window.localStorage.setItem(key, value); + }, + ["sandy-composites", JSON.stringify([composite])], + ); + await page.goto(`/sandbox?s=${encodeState(pageUsingIt)}`); + await page.getByTitle("Open component library").click(); + await page.getByTitle("Delete Placed Card").click(); + }); + + test("says what it will break before doing it", async ({ page }) => { + const confirm = page.getByRole("dialog", { name: /Delete "Placed Card"/ }); + + await expect(confirm).toBeVisible(); + await expect(confirm).toContainText("1 section on the current page"); + await expect(confirm).toContainText("cannot be undone"); + }); + + test("cancelling keeps the component and the page intact", async ({ page }) => { + await page + .getByRole("dialog", { name: /Delete "Placed Card"/ }) + .getByRole("button", { name: "Cancel", exact: true }) + .click(); + + await expect(page.getByRole("dialog", { name: /Delete/ })).toHaveCount(0); + await expect(page.getByTitle("Delete Placed Card")).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(page.locator(".sandy-preview").getByText(/Unknown component/i)).toHaveCount(0); + }); + + test("confirming deletes it", async ({ page }) => { + await page + .getByRole("dialog", { name: /Delete "Placed Card"/ }) + .getByRole("button", { name: "Delete", exact: true }) + .click(); + + await expect(page.getByTitle("Delete Placed Card")).toHaveCount(0); + }); +}); + +test.describe("closing the builder with unsaved work", () => { + test("Escape asks before discarding", async ({ page }) => { + await page.goto("/sandbox"); + await page.getByTitle("Create custom component").click(); + const builder = page.getByRole("dialog", { name: "Component builder" }); + await builder.getByRole("button", { name: "Heading" }).click(); + + await page.keyboard.press("Escape"); + + const confirm = page.getByRole("dialog", { name: "Discard unsaved changes?" }); + await expect(confirm).toBeVisible(); + + // Cancelling returns to the builder with the work still there. + await page + .getByRole("dialog", { name: "Discard unsaved changes?" }) + .getByRole("button", { name: "Cancel", exact: true }) + .click(); + await expect(builder).toBeVisible(); + await expect(builder.getByText("Node Properties", { exact: true })).toBeVisible(); + }); + + test("Escape closes immediately when there is nothing to lose", async ({ page }) => { + await page.goto("/sandbox"); + await page.getByTitle("Create custom component").click(); + const builder = page.getByRole("dialog", { name: "Component builder" }); + + await page.keyboard.press("Escape"); + + await expect(builder).toBeHidden(); + await expect(page.getByRole("dialog", { name: "Discard unsaved changes?" })).toHaveCount(0); + }); + + test("discarding actually closes", async ({ page }) => { + await page.goto("/sandbox"); + await page.getByTitle("Create custom component").click(); + const builder = page.getByRole("dialog", { name: "Component builder" }); + await builder.getByRole("button", { name: "Heading" }).click(); + + await page.keyboard.press("Escape"); + await page + .getByRole("dialog", { name: "Discard unsaved changes?" }) + .getByRole("button", { name: "Discard", exact: true }) + .click(); + + await expect(builder).toBeHidden(); + }); +});