diff --git a/web/src/app/pattern-lab/HardwareModal.test.tsx b/web/src/app/pattern-lab/HardwareModal.test.tsx
index 15e5a536a..87f2d192d 100644
--- a/web/src/app/pattern-lab/HardwareModal.test.tsx
+++ b/web/src/app/pattern-lab/HardwareModal.test.tsx
@@ -155,6 +155,70 @@ describe("HardwareModal", () => {
}
});
+ // A chat answer copied whole carries the model's words and the ``` fence
+ // around the code. The box used to pass it as a header, because the check
+ // found #pragma once on any line, and the words went into the .h whole.
+ it("keeps only the header when a whole chat answer is pasted", async () => {
+ const header = [
+ "// SPDX-License-Identifier: CC-BY-SA-4.0",
+ "// Pattern: Wave",
+ "#pragma once",
+ "namespace LabWave {",
+ ' const char* NAME = "Wave";',
+ " void draw() {}",
+ "} // namespace LabWave",
+ ].join("\n");
+ const reply = `Here is the converted header:\n\n\`\`\`cpp\n${header}\n\`\`\`\n\nLet me know if you want the colours warmer.`;
+ const writeText = vi.fn<(text: string) => Promise>(() => Promise.resolve());
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
+ try {
+ render();
+ fireEvent.change(screen.getByPlaceholderText(/Paste the \.h the model gives you/), {
+ target: { value: reply },
+ });
+ expect(screen.getByText(/Looks like a header ✓/)).toHaveTextContent("The words around it stay out of the .h.");
+
+ fireEvent.click(screen.getByText("Copy .h"));
+ expect(await screen.findByText("Copied ✓")).toBeTruthy();
+ const name = useLabStore.getState().layers[0].name;
+ expect(writeText).toHaveBeenCalledWith(header.replace('"Wave"', `"${name}"`));
+ } finally {
+ delete (navigator as { clipboard?: unknown }).clipboard;
+ }
+ });
+
+ it("finds no header in an answer whose #pragma once is in a sentence", () => {
+ render();
+ fireEvent.change(screen.getByPlaceholderText(/Paste the \.h the model gives you/), {
+ target: { value: "Sure. The header starts with #pragma once and ends with the namespace." },
+ });
+ expect(screen.getByText("A Patternflow header starts with #pragma once")).toBeTruthy();
+ expect(screen.getByText("Copy .h")).toBeDisabled();
+ expect(screen.getByText("Next →")).toBeDisabled();
+ });
+
+ // Copy .h used to be the one button open on an empty box. It put nothing
+ // on the clipboard and said Copied ✓.
+ it("keeps Copy .h shut until a header is pasted", () => {
+ const writeText = vi.fn<(text: string) => Promise>(() => Promise.resolve());
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
+ try {
+ render();
+ expect(screen.getByText("Copy .h")).toBeDisabled();
+ fireEvent.click(screen.getByText("Copy .h"));
+ expect(writeText).not.toHaveBeenCalled();
+ expect(screen.queryByText("Copied ✓")).toBeNull();
+
+ fireEvent.change(screen.getByPlaceholderText(/Paste the \.h the model gives you/), {
+ target: { value: HEADER },
+ });
+ expect(screen.getByText("Looks like a header ✓")).toBeTruthy();
+ expect(screen.getByText("Copy .h")).toBeEnabled();
+ } finally {
+ delete (navigator as { clipboard?: unknown }).clipboard;
+ }
+ });
+
// Text and pixel layers are in the scaffold as pixels, so with no code
// layer the header is whole. The window used to count "0 layers to
// translate — 0 done" and send the person to a model anyway.
diff --git a/web/src/app/pattern-lab/HardwareModal.tsx b/web/src/app/pattern-lab/HardwareModal.tsx
index 9f0542ece..bb080ff5b 100644
--- a/web/src/app/pattern-lab/HardwareModal.tsx
+++ b/web/src/app/pattern-lab/HardwareModal.tsx
@@ -4,7 +4,13 @@ import { useEffect, useMemo, useState, type MouseEvent } from "react";
import { PublishModal, SendModuleModal, buildsConfigured, communityConfigured } from "./community";
import { useDialog } from "@/lib/hooks/useDialog";
import { useResettingState } from "@/lib/hooks/useResettingState";
-import { assembleH, buildHExport, cleanPastedUnit, enforcePatternName } from "@/lib/lab/hExport";
+import {
+ assembleH,
+ buildHExport,
+ cleanPastedHeader,
+ cleanPastedUnit,
+ enforcePatternName,
+} from "@/lib/lab/hExport";
import { buildCppPrompt } from "@/lib/lab/cppPrompt";
import { currentPerformanceJson } from "@/lib/lab/director/publish";
import { needsFlatten } from "@/lib/lab/flatten";
@@ -85,14 +91,21 @@ export default function HardwareModal({
});
}, [layers, activeLayerId, codeLayers, matrix, knobs, ranges, knobLabels, name]);
+ // 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]);
+ const wordsLeftOut = singleHeader !== null && singleHeader !== single.trim();
+
// Single path: the model writes NAME itself, so stamp the lab's name back
// on (the scaffold path already carries it — buildHExport bakes it in).
const assembled = useMemo(
() =>
stacked
? assembleH(exportData.scaffold, pastes)
- : enforcePatternName(single.trim(), name),
- [stacked, exportData.scaffold, pastes, single, name],
+ : singleHeader === null
+ ? ""
+ : enforcePatternName(singleHeader, name),
+ [stacked, exportData.scaffold, pastes, singleHeader, name],
);
// Text and pixel layers go into the scaffold as the pixels they show, so
@@ -287,13 +300,18 @@ export default function HardwareModal({
{headerLooksReal
? "Looks like a header ✓"
: "A Patternflow header starts with #pragma once"}
+ {wordsLeftOut ? " The words around it stay out of the .h." : null}