Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions web/src/app/pattern-lab/HardwareModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>>(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
try {
render(<Lab />);
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(<Lab />);
Expand Down
30 changes: 14 additions & 16 deletions web/src/app/pattern-lab/HardwareModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand All @@ -64,32 +63,31 @@ export default function HardwareModal({
const [publishPerfJson, setPublishPerfJson] = useState<string | null>(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 }),
[name, matrix, layers, knobs, ranges, knobLabels],
);

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.
Expand Down Expand Up @@ -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
Expand Down
9 changes: 3 additions & 6 deletions web/src/app/pattern-lab/PatternLabClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -84,7 +83,8 @@ function premultiplyHex(hex: string, alpha: number): string {

async function buildExportCode(): Promise<string> {
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,
Expand Down Expand Up @@ -113,9 +113,6 @@ async function buildExportCode(): Promise<string> {
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.
Expand Down
18 changes: 14 additions & 4 deletions web/src/lib/lab/flatten.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading