diff --git a/web/src/app/pattern-lab/HardwareModal.test.tsx b/web/src/app/pattern-lab/HardwareModal.test.tsx index 87f2d192d..54a974d15 100644 --- a/web/src/app/pattern-lab/HardwareModal.test.tsx +++ b/web/src/app/pattern-lab/HardwareModal.test.tsx @@ -311,6 +311,41 @@ describe("HardwareModal", () => { expect(screen.queryByText(/Not translated/)).toBeNull(); }); + // With one layer showing, the community gets that layer. The prompt used to + // take the layer picked in Layers even when it was hidden or at 0%, so the + // board got a pattern nobody could see in the preview. + it.each([ + ["hidden", (id: string) => useLabStore.getState().setLayerVisible(id, false)], + ["at 0%", (id: string) => useLabStore.getState().setLayerOpacity(id, 0)], + ])("translates the layer that shows when the picked one is %s", async (_, hide) => { + const lab = useLabStore.getState(); + const starter = lab.layers[0].id; + lab.addCodeLayerFromCode("export function draw(display) { display.setPixel(1, 1, 0, 255, 0); } // shown", "Shown"); + useLabStore.getState().addCodeLayerFromCode("export function draw(display) { display.setPixel(2, 2, 0, 0, 255); } // hidden", "Hidden"); + useLabStore.getState().removeLayer(starter); + const picked = useLabStore.getState().activeLayerId; + expect(useLabStore.getState().layers.find((layer) => layer.id === picked)?.name).toBe("Hidden"); + hide(picked); + const writeText = vi.fn<(text: string) => Promise>(() => Promise.resolve()); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + try { + render(); + fireEvent.click(screen.getByText("Copy the conversion prompt")); + expect(await screen.findByText("Copied ✓")).toBeTruthy(); + const prompt = String(writeText.mock.calls[0][0]); + expect(prompt).toContain("// shown"); + expect(prompt).not.toContain("// hidden"); + + // The build carries the shown layer's name too. + pasteHeaderAndContinue(); + fireEvent.click(screen.getByText("↗ Apply to my Patternflow")); + const request = vi.mocked(fetch).mock.calls.find(([url]) => String(url).endsWith("/api/community/builds")); + expect(JSON.parse(String(request?.[1]?.body)).patterns[0].label).toBe("Shown"); + } finally { + delete (navigator as { clipboard?: unknown }).clipboard; + } + }); + it("a code layer under a text layer is the one to translate before Next", () => { useLabStore.getState().addTextLayer(); render(); diff --git a/web/src/app/pattern-lab/HardwareModal.tsx b/web/src/app/pattern-lab/HardwareModal.tsx index bb080ff5b..6a8116b9b 100644 --- a/web/src/app/pattern-lab/HardwareModal.tsx +++ b/web/src/app/pattern-lab/HardwareModal.tsx @@ -13,10 +13,10 @@ import { } from "@/lib/lab/hExport"; import { buildCppPrompt } from "@/lib/lab/cppPrompt"; import { currentPerformanceJson } from "@/lib/lab/director/publish"; -import { needsFlatten } from "@/lib/lab/flatten"; +import { loneCodeLayer } from "@/lib/lab/flatten"; import { labPatternName, useLabStore } from "@/lib/lab/store"; import { activeTextEffect } from "@/lib/lab/textEffects"; -import { isCodeLayer, isTextLayer } from "@/lib/lab/types"; +import { isTextLayer } from "@/lib/lab/types"; import styles from "./PatternLab.module.css"; import local from "./HardwareModal.module.css"; @@ -48,7 +48,6 @@ export default function HardwareModal({ const knobs = useLabStore((state) => state.knobs); const ranges = useLabStore((state) => state.ranges); const knobLabels = useLabStore((state) => state.knobLabels); - const activeLayerId = useLabStore((state) => state.activeLayerId); const forkOf = useLabStore((state) => state.forkOf); // Seeded from the lab's pattern name so the .h NAME, the .pfm slug and the @@ -64,11 +63,12 @@ export default function HardwareModal({ const [publishPerfJson, setPublishPerfJson] = useState(null); // One visible code layer is the simple case: a single prompt and a single - // paste. Anything else goes through the scaffold. - const codeLayers = layers.filter(isCodeLayer); - // Same test the export path uses (masks count; a lone pixel layer is a - // stack, not a pattern with an empty prompt). - const stacked = needsFlatten(layers); + // paste. Anything else goes through the scaffold. Same test the export path + // uses (masks count; a lone pixel layer is a stack, not a pattern with an + // empty prompt), and the same layer: the one showing, whichever is picked + // in Layers. A hidden pick used to go into the prompt in its place. + const lone = loneCodeLayer(layers); + const stacked = lone === null; const exportData = useMemo( () => buildHExport({ name, matrix, layers, knobs, ranges, knobLabels }), @@ -76,20 +76,18 @@ export default function HardwareModal({ ); const singlePrompt = useMemo(() => { - const active = layers.find((layer) => layer.id === activeLayerId); - const focus = isCodeLayer(active) ? active : codeLayers[0]; - if (!focus) return ""; + if (!lone) return ""; return buildCppPrompt({ - code: focus.code, + code: lone.code, matrix, knobs, ranges, knobLabels, - ramp: focus.ramp, - recolor: focus.recolor, + ramp: lone.ramp, + recolor: lone.recolor, name, }); - }, [layers, activeLayerId, codeLayers, matrix, knobs, ranges, knobLabels, name]); + }, [lone, 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. @@ -157,7 +155,7 @@ export default function HardwareModal({ URL.revokeObjectURL(url); }; - const label = stacked ? name : codeLayers[0]?.name ?? "Pattern"; + 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 diff --git a/web/src/app/pattern-lab/PatternLabClient.tsx b/web/src/app/pattern-lab/PatternLabClient.tsx index b141ee5da..3e3454dbb 100644 --- a/web/src/app/pattern-lab/PatternLabClient.tsx +++ b/web/src/app/pattern-lab/PatternLabClient.tsx @@ -32,13 +32,12 @@ import { import { withKnobsAnnotation } from "@/lib/lab/annotations"; import { currentPerformanceJson } from "@/lib/lab/director/publish"; import { onEditorReveal } from "@/lib/lab/editorReveal"; -import { flattenLayers, needsFlatten } from "@/lib/lab/flatten"; +import { flattenLayers, loneCodeLayer } from "@/lib/lab/flatten"; import { readStorage, removeStorage, writeStorage, type LabStorageKey } from "@/lib/lab/persist"; import { LAYOUT_NARROW_STORAGE, LAYOUT_STORAGE, dropRetiredPanels, layoutViewCount } from "@/lib/lab/serialize"; import { listSessions, type SessionMeta } from "@/lib/lab/sessions"; import { buildStackAnnotation, stripStackAnnotation } from "@/lib/lab/stackShare"; import { flushLabProject, saveLabProjectNow, useLabStore } from "@/lib/lab/store"; -import type { CodeLayer } from "@/lib/lab/types"; import HardwareModal from "./HardwareModal"; import { openIncomingPattern } from "./openIncomingPattern"; import { @@ -84,7 +83,8 @@ function premultiplyHex(hex: string, alpha: number): string { async function buildExportCode(): Promise { const state = useLabStore.getState(); - if (needsFlatten(state.layers)) { + const layer = loneCodeLayer(state.layers); + if (!layer) { const flat = flattenLayers(state.layers, state.matrix, { labels: state.knobLabels, ranges: state.ranges, @@ -113,9 +113,6 @@ async function buildExportCode(): Promise { const withStack = stackLine ? `${flat}\n\n${stackLine}\n` : flat; return withStack.length <= CODE_MAX ? withStack : flat; } - const layer = state.layers.find( - (entry) => entry.visible && entry.opacity > 0 && entry.type === "code", - ) as CodeLayer; // A layer that arrived from a shared composition may still carry that // composition's @stack line; published as-is it would reopen as the // original author's layers rather than this pattern. diff --git a/web/src/lib/lab/flatten.ts b/web/src/lib/lab/flatten.ts index a64a44dd4..50451bc81 100644 --- a/web/src/lib/lab/flatten.ts +++ b/web/src/lib/lab/flatten.ts @@ -172,13 +172,23 @@ function __fxDraw(L) { } }`; -/** Does this composition even need flattening, or is it a single plain pattern? */ -export function needsFlatten(layers: Layer[]): boolean { +/** + * The code layer a single plain pattern is made of: the one layer left + * showing. Null when the composition is a stack. The community upload and + * To hardware both take this layer, so the board gets what was published. + */ +export function loneCodeLayer(layers: Layer[]): CodeLayer | null { const visible = layers.filter( (layer) => layer.visible && (layer.role === "mask" || layer.opacity > 0), ); - if (visible.length !== 1) return true; - return visible[0].type !== "code" || visible[0].role === "mask"; + if (visible.length !== 1) return null; + const [layer] = visible; + return layer.type === "code" && layer.role !== "mask" ? layer : null; +} + +/** Does this composition even need flattening, or is it a single plain pattern? */ +export function needsFlatten(layers: Layer[]): boolean { + return loneCodeLayer(layers) === null; } export function flattenLayers(