From 008d6a3bb6bad501c720dda037dbd8bae39e2526 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:54:14 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20Patternflo?= =?UTF-8?q?w=203=EB=B0=94=ED=80=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BL8iimZp7KVXdh7mzkP7ex From d1a9eda5878264ba13ea0c734615155bc9a142d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:56:52 +0000 Subject: [PATCH 2/4] web: ask before Del removes a Pattern Lab layer that holds work Layer deletion has no undo and autosave overwrites the saved project within 3 s, so one misclick lost a whole pixel drawing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BL8iimZp7KVXdh7mzkP7ex --- .../pattern-lab/panels/LayersPanel.test.tsx | 81 +++++++++++++++++++ .../app/pattern-lab/panels/LayersPanel.tsx | 26 +++++- 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 web/src/app/pattern-lab/panels/LayersPanel.test.tsx diff --git a/web/src/app/pattern-lab/panels/LayersPanel.test.tsx b/web/src/app/pattern-lab/panels/LayersPanel.test.tsx new file mode 100644 index 00000000..ab8c98e5 --- /dev/null +++ b/web/src/app/pattern-lab/panels/LayersPanel.test.tsx @@ -0,0 +1,81 @@ +// Del in the layer stack. There is no undo for it, so a layer with work in it +// asks first; a blank one goes straight away. + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import LayersPanel from "./LayersPanel"; +import { useLabStore } from "@/lib/lab/store"; + +const store = () => useLabStore.getState(); +const names = () => store().layers.map((layer) => layer.name); +const del = () => screen.getByRole("button", { name: "Del" }); + +function drawOnePixel() { + const layer = store().layers.find((entry) => entry.id === store().activeLayerId); + if (!layer || layer.type !== "pixel") throw new Error("active layer is not pixel"); + const data = new Uint8ClampedArray(layer.data); + data.set([255, 77, 0, 255], 0); + store().replacePixelData(layer.id, data); +} + +beforeEach(() => { + store().discardProject(); +}); + +describe("LayersPanel Del", () => { + it("is disabled while the stack has one layer", () => { + render(); + expect(del()).toBeDisabled(); + }); + + it("deletes a pixel layer nobody has drawn on without asking", () => { + store().addPixelLayer(); + const confirm = vi.spyOn(window, "confirm"); + render(); + fireEvent.click(del()); + expect(confirm).not.toHaveBeenCalled(); + expect(names()).toEqual(["Code 1"]); + }); + + it("deletes a code layer still holding the starter pattern without asking", () => { + store().addCodeLayer(); + const confirm = vi.spyOn(window, "confirm"); + render(); + fireEvent.click(del()); + expect(confirm).not.toHaveBeenCalled(); + expect(names()).toEqual(["Code 1"]); + }); + + it("asks before deleting a drawn pixel layer, and Cancel keeps it", () => { + store().addPixelLayer(); + drawOnePixel(); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + render(); + fireEvent.click(del()); + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm.mock.calls[0][0]).toContain('"Pixel 1"'); + expect(names()).toEqual(["Pixel 1", "Code 1"]); + const kept = store().layers[0]; + expect(kept.type === "pixel" ? kept.data[3] : -1).toBe(255); + }); + + it("deletes the drawn pixel layer once confirmed", () => { + store().addPixelLayer(); + drawOnePixel(); + vi.spyOn(window, "confirm").mockReturnValue(true); + render(); + fireEvent.click(del()); + expect(names()).toEqual(["Code 1"]); + expect(store().activeLayerId).toBe(store().layers[0].id); + }); + + it("asks before deleting a code layer whose code was changed", () => { + store().addPixelLayer(); + store().selectLayer(store().layers[1].id); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + render(); + fireEvent.click(del()); + expect(confirm.mock.calls[0][0]).toContain('"Code 1"'); + expect(names()).toEqual(["Pixel 1", "Code 1"]); + }); +}); diff --git a/web/src/app/pattern-lab/panels/LayersPanel.tsx b/web/src/app/pattern-lab/panels/LayersPanel.tsx index 15d51895..c091f416 100644 --- a/web/src/app/pattern-lab/panels/LayersPanel.tsx +++ b/web/src/app/pattern-lab/panels/LayersPanel.tsx @@ -5,11 +5,22 @@ // The active layer's opacity + blend live at the bottom of the panel. import { useState } from "react"; -import { BLEND_MODES } from "@/lib/lab/types"; +import { BLEND_MODES, type Layer } from "@/lib/lab/types"; import { revealEditor } from "@/lib/lab/editorReveal"; import { useLabStore } from "@/lib/lab/store"; +import { STARTER_CODE } from "@/lib/lab/store/shared"; import dock from "../LabPanels.module.css"; +// A pixel layer nobody has drawn on, or a code layer still holding the +// starter pattern, has nothing to lose, so Del takes it without asking. +function holdsWork(layer: Layer): boolean { + if (layer.type === "code") return layer.code !== STARTER_CODE; + for (let i = 3; i < layer.data.length; i += 4) { + if (layer.data[i] !== 0) return true; + } + return false; +} + export default function LayersPanel() { const layers = useLabStore((state) => state.layers); const activeLayerId = useLabStore((state) => state.activeLayerId); @@ -77,7 +88,18 @@ export default function LayersPanel() {