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(); + }); +});