From 5ed9a5da59f497919b1cd707db5c1f637f4cac72 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 03:26:18 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20=EB=B0=94?= =?UTF-8?q?=ED=80=B4=2077?= 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_01XQHHGQiXZWWSWXzZb2pNyp From e49c1fdefe9b4b8754ec73673e4deb49f79f4642 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 03:33:27 +0000 Subject: [PATCH 2/6] =?UTF-8?q?web:=20To=20hardware=20=EC=97=90=20?= =?UTF-8?q?=EB=B6=99=EC=9D=B8=20=ED=97=A4=EB=8D=94=EA=B0=80=20=EC=B0=BD?= =?UTF-8?q?=EC=9D=84=20=EB=8B=AB=EA=B1=B0=EB=82=98=20=EC=AA=BD=EC=9D=84=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EB=B6=88=EB=9F=AC=EC=99=80=EB=8F=84=20?= =?UTF-8?q?=EB=82=A8=EB=8A=94=EB=8B=A4?= 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_01XQHHGQiXZWWSWXzZb2pNyp --- .../app/pattern-lab/HardwareModal.test.tsx | 143 +++++++++++++++++- web/src/app/pattern-lab/HardwareModal.tsx | 68 ++++++++- web/src/lib/lab/hardwarePastes.ts | 60 ++++++++ web/src/lib/lab/persist.ts | 8 +- 4 files changed, 263 insertions(+), 16 deletions(-) create mode 100644 web/src/lib/lab/hardwarePastes.ts diff --git a/web/src/app/pattern-lab/HardwareModal.test.tsx b/web/src/app/pattern-lab/HardwareModal.test.tsx index 3d835eb60..fc5e278fe 100644 --- a/web/src/app/pattern-lab/HardwareModal.test.tsx +++ b/web/src/app/pattern-lab/HardwareModal.test.tsx @@ -15,6 +15,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import HardwareModal from "./HardwareModal"; import { useLabStore } from "@/lib/lab/store"; +import { PROJECT_STORAGE, serializeProject } from "@/lib/lab/serialize"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }) })); vi.mock("@/lib/community/auth-client", () => ({ @@ -26,6 +27,10 @@ vi.mock("@/lib/community/auth-client", () => ({ const HEADER = "#pragma once\nnamespace Lab { void draw() {} }\n"; const CODE = "export function draw(display) { display.setPixel(0, 0, 255, 0, 0); }"; +/** What a model sends back for one layer of a stack. */ +const unitBlock = (index: number) => + `namespace L${index} {\n void setup() {}\n void update(float dt, const InputFrame& input) {}\n void draw() {}\n}`; + function Lab() { const [open, setOpen] = useState(true); return open ? ( @@ -39,11 +44,17 @@ function hardware() { return screen.getByRole("dialog", { name: "Hardware" }); } +function headerBox() { + return screen.getByPlaceholderText(/Paste the \.h the model gives you/); +} + +function pasteHeader() { + fireEvent.change(headerBox(), { target: { value: HEADER } }); +} + /** Paste a header, press Next, and land on the "On to hardware" stage. */ function pasteHeaderAndContinue() { - fireEvent.change(screen.getByPlaceholderText(/Paste the \.h the model gives you/), { - target: { value: HEADER }, - }); + pasteHeader(); fireEvent.click(screen.getByText("Next →")); expect(screen.getByText("On to hardware")).toBeTruthy(); } @@ -57,6 +68,8 @@ function expectHeaderKept() { beforeEach(() => { useLabStore.getState().discardProject(); + // The modal keeps what was pasted for the tab, and jsdom's tab is the whole file. + sessionStorage.clear(); vi.stubEnv("NEXT_PUBLIC_BUILD_ENABLED", "1"); vi.stubEnv("NEXT_PUBLIC_COMMUNITY_URL", "https://patternflow.work"); // Nothing here should reach the network; a request that does stays unanswered. @@ -283,8 +296,6 @@ describe("HardwareModal", () => { it("a stack of two code layers waits for one translation, then names the stub left", () => { useLabStore.getState().addCodeLayer(); const bottom = useLabStore.getState().layers[1]; - const unit = (index: number) => - `namespace L${index} {\n void setup() {}\n void update(float dt, const InputFrame& input) {}\n void draw() {}\n}`; render(); expect(screen.getByText(/2 layers to translate — 0 done/)).toHaveTextContent( @@ -294,7 +305,7 @@ describe("HardwareModal", () => { fireEvent.click(screen.getByText("Next →")); expect(screen.queryByText("On to hardware")).toBeNull(); - fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L1 \{/), { target: { value: unit(1) } }); + fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L1 \{/), { target: { value: unitBlock(1) } }); expect(screen.getByText(/1 done/)).not.toHaveTextContent("Next opens"); fireEvent.click(screen.getByText("Next →")); expect(screen.getByText("On to hardware")).toBeTruthy(); @@ -305,7 +316,7 @@ describe("HardwareModal", () => { ); fireEvent.click(screen.getByText("← Back to the header")); - fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L0 \{/), { target: { value: unit(0) } }); + fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L0 \{/), { target: { value: unitBlock(0) } }); fireEvent.click(screen.getByText("Next →")); expect(screen.getByText(/Header ready \([\d.]+ KB\)\./)).toBeTruthy(); expect(screen.queryByText(/Not translated/)).toBeNull(); @@ -382,4 +393,122 @@ describe("HardwareModal", () => { await waitFor(() => expect(screen.queryByText("Share to Community")).toBeNull()); expectHeaderKept(); }); + + // The header a model translated lived in this modal's state alone. Closing + // the window any way at all, or reloading the page, emptied the box, and + // the way back was to ask the model again. + it.each([ + ["Esc", () => fireEvent.keyDown(document.activeElement!, { key: "Escape" })], + ["a press outside", () => fireEvent.click(hardware())], + ["×", () => fireEvent.click(screen.getByRole("button", { name: "Close" }))], + ])("opens again with the header it held before %s", (_, close) => { + render(); + pasteHeader(); + close(); + expect(screen.getByText("closed")).toBeTruthy(); + cleanup(); + + render(); + expect(headerBox()).toHaveValue(HEADER); + expect(screen.getByText("Looks like a header ✓")).toBeTruthy(); + expect(screen.getByText("Next →")).toBeEnabled(); + }); + + it("opens again with the header after a reload", () => { + const lab = useLabStore.getState(); + const starter = lab.layers[0].id; + lab.addCodeLayerFromCode(CODE, "Dot"); + useLabStore.getState().removeLayer(starter); + render(); + pasteHeader(); + cleanup(); + + // What the lab's autosave writes, read back the way an open reads it. + const state = useLabStore.getState(); + const saved = serializeProject({ + name: state.name, + matrix: state.matrix, + layers: state.layers, + activeLayerId: state.activeLayerId, + knobs: state.knobs, + ranges: state.ranges, + knobLabels: state.knobLabels, + forkOf: state.forkOf, + editOf: state.editOf, + gen: state.gen, + director: state.director, + }); + state.discardProject(); + localStorage.setItem(PROJECT_STORAGE, saved!); + useLabStore.setState({ hydrated: false }); + useLabStore.getState().hydrate(); + expect(useLabStore.getState().layers.map((layer) => layer.name)).toEqual(["Dot"]); + + render(); + expect(headerBox()).toHaveValue(HEADER); + }); + + it("opens a stack again with the layers already translated", () => { + useLabStore.getState().addCodeLayer(); + render(); + fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L1 \{/), { target: { value: unitBlock(1) } }); + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + cleanup(); + + render(); + expect(screen.getByText(/2 layers to translate — 1 done/)).toBeTruthy(); + expect(screen.getByPlaceholderText(/Paste the namespace L1 \{/)).toHaveValue(unitBlock(1)); + expect(screen.getByPlaceholderText(/Paste the namespace L0 \{/)).toHaveValue(""); + expect(screen.getByText("Next →")).toBeEnabled(); + }); + + // A knob position only says where the board starts that knob. Asking the + // model again for it would cost more than it fixes. + it("keeps the header when a knob turns", () => { + render(); + pasteHeader(); + cleanup(); + + const [min, max] = useLabStore.getState().ranges[0]; + const knob = useLabStore.getState().knobs[0]; + useLabStore.getState().setKnob(0, knob === max ? min : max); + expect(useLabStore.getState().knobs[0]).not.toBe(knob); + render(); + expect(headerBox()).toHaveValue(HEADER); + }); + + // The header answers one prompt. Once the lab asks another, it would put + // the old pattern, frame or colours on the board while saying ready. + it.each([ + ["the code", (id: string) => { + const layer = useLabStore.getState().layers.find((each) => each.id === id); + if (layer?.type !== "code") throw new Error("not code"); + useLabStore.getState().updateLayerCode(id, `${layer.code}\n// tuned`); + }], + ["the ramp mode", (id: string) => { + const layer = useLabStore.getState().layers.find((each) => each.id === id); + if (layer?.type !== "code") throw new Error("not code"); + useLabStore.getState().setLayerRampMode(id, layer.ramp.mode === "step" ? "linear" : "step"); + }], + ["the matrix", () => useLabStore.getState().setMatrix({ width: 64, height: 64 })], + ])("opens empty once %s changes, and keeps nothing for it", (_, change) => { + render(); + pasteHeader(); + cleanup(); + + change(useLabStore.getState().layers[0].id); + render(); + expect(headerBox()).toHaveValue(""); + expect(screen.getByText("Next →")).toBeDisabled(); + }); + + it("forgets a header the box was emptied of", () => { + render(); + pasteHeader(); + fireEvent.change(headerBox(), { target: { value: "" } }); + cleanup(); + + render(); + expect(headerBox()).toHaveValue(""); + }); }); diff --git a/web/src/app/pattern-lab/HardwareModal.tsx b/web/src/app/pattern-lab/HardwareModal.tsx index c9cd18e82..f69c41c72 100644 --- a/web/src/app/pattern-lab/HardwareModal.tsx +++ b/web/src/app/pattern-lab/HardwareModal.tsx @@ -12,6 +12,7 @@ import { enforcePatternName, } from "@/lib/lab/hExport"; import { buildCppPrompt } from "@/lib/lab/cppPrompt"; +import { promptKey, readPastes, writePastes } from "@/lib/lab/hardwarePastes"; import { currentPerformanceJson } from "@/lib/lab/director/publish"; import { loneCodeLayer } from "@/lib/lab/flatten"; import { labPatternName, useLabStore } from "@/lib/lab/store"; @@ -53,8 +54,6 @@ export default function HardwareModal({ // Seeded from the lab's pattern name so the .h NAME, the .pfm slug and the // Director's opening pattern cue all agree without retyping. const [name, setName] = useState(() => labPatternName(useLabStore.getState())); - const [pastes, setPastes] = useState>({}); - const [single, setSingle] = useState(""); const [copied, flashCopied] = useResettingState<{ key: string; ok: boolean } | null>(null, 1200); const [stage, setStage] = useState("convert"); @@ -91,6 +90,58 @@ export default function HardwareModal({ }); }, [lone, matrix, knobs, ranges, knobLabels, name]); + // What the model answered is kept for the tab under the prompt it answers + // (hardwarePastes.ts), so Esc, a press outside or a reload no longer sends + // the person back to ask again. The single key is the prompt without the + // name, which assembly stamps on again, and without the knob positions, + // which the key leaves out anyway. + const singleKey = useMemo( + () => + lone + ? promptKey( + buildCppPrompt({ + code: lone.code, + matrix, + knobs: [], + ranges, + knobLabels, + ramp: lone.ramp, + recolor: lone.recolor, + }), + ) + : "", + [lone, matrix, ranges, knobLabels], + ); + const unitKeys = useMemo( + () => exportData.units.map((unit) => [unit.index, promptKey(unit.prompt)] as const), + [exportData.units], + ); + + const [single, setSingle] = useState(() => (lone ? (readPastes()[singleKey] ?? "") : "")); + const [pastes, setPastes] = useState>(() => { + const kept = readPastes(); + const restored: Record = {}; + for (const [index, key] of unitKeys) { + if (kept[key]) restored[index] = kept[key]; + } + return restored; + }); + + // Keeps what the boxes hold now and nothing else, so a paste whose prompt + // the lab no longer asks goes with the next edit. + const keep = (nextSingle: string, nextPastes: Record) => { + const kept: Record = {}; + if (lone) { + if (nextSingle.trim()) kept[singleKey] = nextSingle; + } else { + for (const [index, key] of unitKeys) { + const text = nextPastes[index] ?? ""; + if (text.trim()) kept[key] = text; + } + } + writePastes(kept); + }; + // A chat answer pasted whole used to go into the .h with the model's words // and the ``` fence, and the header test below passed it. const singleHeader = useMemo(() => cleanPastedHeader(single), [single]); @@ -270,9 +321,11 @@ export default function HardwareModal({