diff --git a/web/src/app/pattern-lab/HardwareModal.test.tsx b/web/src/app/pattern-lab/HardwareModal.test.tsx
index 3d835eb60..9bc38be00 100644
--- a/web/src/app/pattern-lab/HardwareModal.test.tsx
+++ b/web/src/app/pattern-lab/HardwareModal.test.tsx
@@ -7,14 +7,15 @@
// hears the Esc meant for them, and it closes the one on top.
//
// The modal is mounted the way the lab mounts it, behind an open flag that
-// onClose clears, so a stray close really unmounts it and the pasted header
-// is really gone.
+// onClose clears, so a stray close really unmounts it. The pasted header is
+// kept for the tab, and rendering Lab again is opening the window again.
import { useState } from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
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,138 @@ 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.
+ const turnKnob = () => {
+ 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);
+ };
+
+ it("keeps the header when a knob turns", () => {
+ render();
+ pasteHeader();
+ cleanup();
+
+ turnKnob();
+ render();
+ expect(headerBox()).toHaveValue(HEADER);
+ });
+
+ // Each layer's prompt in a stack lists the knobs with where they sit now.
+ it("keeps a stack's translations when a knob turns", () => {
+ useLabStore.getState().addCodeLayer();
+ render();
+ fireEvent.change(screen.getByPlaceholderText(/Paste the namespace L1 \{/), { target: { value: unitBlock(1) } });
+ cleanup();
+
+ turnKnob();
+ render();
+ expect(screen.getByPlaceholderText(/Paste the namespace L1 \{/)).toHaveValue(unitBlock(1));
+ });
+
+ // 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..ebbd53dc5 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]);
@@ -160,8 +211,8 @@ export default function HardwareModal({
const label = lone?.name ?? name;
// Only a press on this backdrop itself closes. Send and Publish open inside
- // it, so a press on their backdrop bubbles up here too, and the header
- // pasted above lives in this component's state and would go with it.
+ // it, so a press on their backdrop bubbles up here too, and closing one of
+ // them would close this window under it.
const closeOnBackdrop = (event: MouseEvent) => {
if (event.target === event.currentTarget) onClose();
};
@@ -270,9 +321,11 @@ export default function HardwareModal({