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
13 changes: 13 additions & 0 deletions web/src/app/pattern-lab/panels/CodePanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,16 @@ button.promptButton {
align-items: center;
gap: 6px;
}

/* Under the bar while a typed @matrix line waits for the editor to lose
focus, or when the Lab cannot read it. */
.matrixNote {
flex: 0 0 auto;
padding: 6px 8px;
border-bottom: 1px solid rgba(23, 21, 18, 0.12);
color: #756f64;
font-family: var(--font-inter), system-ui, sans-serif;
font-size: 12px;
line-height: 1.5;
text-wrap: pretty;
}
74 changes: 73 additions & 1 deletion web/src/app/pattern-lab/panels/CodePanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,37 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import CodePanel from "./CodePanel";
import { useLabStore } from "@/lib/lab/store";

vi.mock("@monaco-editor/react", () => ({ default: () => <textarea aria-label="Code" /> }));
// A textarea standing in for Monaco: typing is onChange, and leaving it is the
// blur listener the panel registers in onMount.
vi.mock("@monaco-editor/react", async () => {
const { useEffect, useRef } = await import("react");
type Props = {
value?: string;
onChange?: (value: string | undefined) => void;
onMount?: (editor: { onDidBlurEditorWidget: (listener: () => void) => void }) => void;
};
return {
default: function Editor({ value, onChange, onMount }: Props) {
const blur = useRef<(() => void) | null>(null);
const mount = useRef(onMount);
useEffect(() => {
mount.current?.({
onDidBlurEditorWidget: (listener) => {
blur.current = listener;
},
});
}, []);
return (
<textarea
aria-label="Code"
value={value}
onChange={(event) => onChange?.(event.target.value)}
onBlur={() => blur.current?.()}
/>
);
},
};
});

function setClipboard(value: unknown) {
Object.defineProperty(navigator, "clipboard", { configurable: true, value });
Expand Down Expand Up @@ -98,3 +128,45 @@ describe("CodePanel colour mode", () => {
expect(writeText.mock.calls[1][0]).not.toContain("Do NOT call display.setPixel");
});
});

// The @matrix line, typed. Until 24 October 2026 each keystroke changed the
// frame, so typing 96 over 128 cropped every pixel layer to 12, then 9
// columns (lib/lab/store/layers.test.ts has the numbers).
describe("CodePanel @matrix line", () => {
const BODY = "export function draw(display) {}\n";

it("changes the frame when focus leaves the editor, not while typing", () => {
render(<CodePanel />);
const editor = screen.getByLabelText("Code");

fireEvent.change(editor, { target: { value: `// @matrix 9x64\n${BODY}` } });
fireEvent.change(editor, { target: { value: `// @matrix 96x64\n${BODY}` } });
expect(useLabStore.getState().matrix).toEqual({ width: 128, height: 64 });
expect(screen.getByRole("status").textContent).toBe("The frame becomes 96×64 when you leave the editor.");

fireEvent.blur(editor);
expect(useLabStore.getState().matrix).toEqual({ width: 96, height: 64 });
expect(screen.queryByRole("status")).toBeNull();
});

it("says the frame stays when the line is out of range", () => {
render(<CodePanel />);
const editor = screen.getByLabelText("Code");

fireEvent.change(editor, { target: { value: `// @matrix 1x64\n${BODY}` } });
fireEvent.blur(editor);
expect(useLabStore.getState().matrix).toEqual({ width: 128, height: 64 });
expect(screen.getByRole("status").textContent).toBe(
"The @matrix line needs each side from 8 to 512. The frame stays 128×64.",
);
});

it("takes up a held line when the panel closes", () => {
const { unmount } = render(<CodePanel />);
fireEvent.change(screen.getByLabelText("Code"), { target: { value: `// @matrix 64x64\n${BODY}` } });
expect(useLabStore.getState().matrix).toEqual({ width: 128, height: 64 });

unmount();
expect(useLabStore.getState().matrix).toEqual({ width: 64, height: 64 });
});
});
46 changes: 43 additions & 3 deletions web/src/app/pattern-lab/panels/CodePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,17 @@
// single-pattern workflow: preset stepper, paste/clear, the AI variation
// prompt with the colour model it asks for, and the code guide.

import Editor from "@monaco-editor/react";
import { useState } from "react";
import Editor, { type OnMount } from "@monaco-editor/react";
import { useEffect, useRef, useState } from "react";
import { livePresets } from "@/lib/presets";
import {
MATRIX_MAX,
MATRIX_MIN,
formatMatrix,
matchMatrixAnnotation,
matrixesEqual,
parseMatrixAnnotation,
} from "@/lib/pattern/matrix";
import { COLOR_MODES, type ColorMode } from "@/lib/ai/settings";
import { buildVariantCopyPrompt } from "@/lib/ai/variantPrompt";
import { useResettingState } from "@/lib/hooks/useResettingState";
Expand All @@ -28,6 +36,7 @@ export default function CodePanel() {
const gen = useLabStore((state) => state.gen);
const setGen = useLabStore((state) => state.setGen);
const updateLayerCode = useLabStore((state) => state.updateLayerCode);
const applyMatrixLine = useLabStore((state) => state.applyMatrixLine);
const applyCodeToActive = useLabStore((state) => state.applyCodeToActive);
const addCodeLayer = useLabStore((state) => state.addCodeLayer);
const error = useLabStore((state) =>
Expand All @@ -39,6 +48,21 @@ export default function CodePanel() {
const [cleared, setCleared] = useResettingState(false, 1200);
const [guideOpen, setGuideOpen] = useState(false);

// Typing holds the @matrix line (holdMatrix below). The frame follows it
// when focus leaves the editor, or the panel leaves the layer.
const codeLayerId = isCodeLayer(layer) ? layer.id : null;
const editingId = useRef(codeLayerId);
useEffect(() => {
editingId.current = codeLayerId;
if (!codeLayerId) return;
return () => applyMatrixLine(codeLayerId);
}, [codeLayerId, applyMatrixLine]);
const applyMatrixOnBlur: OnMount = (editor) => {
editor.onDidBlurEditorWidget(() => {
if (editingId.current) applyMatrixLine(editingId.current);
});
};

if (!isCodeLayer(layer)) {
return (
<div className={dock.panel}>
Expand All @@ -56,6 +80,16 @@ export default function CodePanel() {

const activeLabIndex = labPresets.findIndex((preset) => preset.code === layer.code);

const matrixLine = matchMatrixAnnotation(layer.code);
const lineFrame = parseMatrixAnnotation(layer.code);
const matrixNote = !matrixLine
? null
: !lineFrame
? `The @matrix line needs each side from ${MATRIX_MIN} to ${MATRIX_MAX}. The frame stays ${formatMatrix(matrix)}.`
: matrixLine !== layer.matrixAnnotationRaw && !matrixesEqual(lineFrame, matrix)
? `The frame becomes ${formatMatrix(lineFrame)} when you leave the editor.`
: null;

const stepLabPreset = (dir: number) => {
if (labPresets.length === 0) return;
const base = activeLabIndex >= 0 ? activeLabIndex : dir > 0 ? -1 : 0;
Expand Down Expand Up @@ -169,6 +203,11 @@ export default function CodePanel() {
</div>

{error && <div className={styles.errorBox}>{error}</div>}
{matrixNote && (
<div className={local.matrixNote} role="status">
{matrixNote}
</div>
)}

<div className={dock.panelBody}>
<Editor
Expand All @@ -177,7 +216,8 @@ export default function CodePanel() {
theme="vs-dark"
path={`layer-${layer.id}.js`}
value={layer.code}
onChange={(value) => updateLayerCode(layer.id, value ?? "")}
onChange={(value) => updateLayerCode(layer.id, value ?? "", { holdMatrix: true })}
onMount={applyMatrixOnBlur}
options={{
minimap: { enabled: false },
fontSize: 14,
Expand Down
42 changes: 42 additions & 0 deletions web/src/lib/lab/pixelHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,45 @@ describe("pixel undo", () => {
expect(undoPixels("kept", layer)).toBe(true);
});
});

// Since 24 October 2026 a frame change snapshots the layer before it crops or
// pads it (resizePixelLayers). Right after the change that snapshot is the
// picture as it is, so undo goes past it, and only past that kind.
describe("pixel undo across a frame change's snapshot", () => {
it("goes past one that changes nothing, to the stroke before", () => {
const layer = blank(4, 2);
snapshotPixels("a", layer);
paint(layer, 1, 0, RED);
snapshotPixels("a", layer, true);
const small = resized(layer, 2, 2);

expect(undoPixels("a", small)).toBe(true);
expect(rgba(small, 0, 0)).toEqual(CLEAR);
expect(redoPixels("a", small)).toBe(true);
expect(rgba(small, 0, 0)).toEqual(RED);
});

it("restores one that brings back what a smaller frame cut off", () => {
const layer = blank(4, 2);
paint(layer, 0, 0, RED);
snapshotPixels("a", layer, true);
const small = resized(layer, 2, 2);
snapshotPixels("a", small, true);
const grown = resized(small, 4, 2);
expect(rgba(grown, 0, 0)).toEqual(CLEAR);

expect(undoPixels("a", grown)).toBe(true);
expect(rgba(grown, 0, 0)).toEqual(RED);
});

it("still takes a stroke's snapshot that changes nothing as a step", () => {
// Esc on a selection lifted from empty pixels undoes the lift, and only it.
const layer = blank(2, 2);
snapshotPixels("a", layer);
paint(layer, 1, 1, BLUE);
snapshotPixels("a", layer);

expect(undoPixels("a", layer)).toBe(true);
expect(rgba(layer, 1, 1)).toEqual(BLUE);
});
});
34 changes: 26 additions & 8 deletions web/src/lib/lab/pixelHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
// React: buffers, not state. The Pixel panel calls snapshot() before a stroke,
// undo()/redo() on the keys, and prune() with the live layer ids so a deleted
// layer's stacks — up to MAX_UNDO frames of RGBA — do not live for the tab.
// A frame change calls snapshot() too, before it crops or pads the layer.
// Right after the change that snapshot, put back at the new size, is the
// picture as it is, so undo goes past it to the step before. It counts once
// the frame grows back and it brings in what the smaller frame cut off.
//
// A frame keeps the size it was taken at. Changing the matrix size gives the
// layer a new buffer under the same id (resizePixelLayers in store/shared.ts),
Expand All @@ -13,7 +17,8 @@ import { resizeSurface, type PixelSurface } from "./pixelTools";

export const MAX_UNDO = 40;

type History = { undo: PixelSurface[]; redo: PixelSurface[] };
type Frame = PixelSurface & { frameChange: boolean };
type History = { undo: Frame[]; redo: Frame[] };

const histories = new Map<string, History>();

Expand All @@ -26,26 +31,39 @@ function historyFor(id: string): History {
return history;
}

function frameOf(surface: PixelSurface): PixelSurface {
return { data: new Uint8ClampedArray(surface.data), width: surface.width, height: surface.height };
function frameOf(surface: PixelSurface, frameChange = false): Frame {
return { data: new Uint8ClampedArray(surface.data), width: surface.width, height: surface.height, frameChange };
}

function restore(surface: PixelSurface, frame: PixelSurface): void {
surface.data.set(resizeSurface(frame.data, frame.width, frame.height, surface.width, surface.height));
}

/** Remember the surface as it is now, before it changes. Clears the redo stack. */
export function snapshotPixels(id: string, surface: PixelSurface): void {
function showsTheSame(frame: PixelSurface, surface: PixelSurface): boolean {
const data = resizeSurface(frame.data, frame.width, frame.height, surface.width, surface.height);
for (let i = 0; i < data.length; i++) if (data[i] !== surface.data[i]) return false;
return true;
}

/**
* Remember the surface as it is now, before it changes. Clears the redo stack.
* `frameChange` marks the snapshot a matrix resize takes.
*/
export function snapshotPixels(id: string, surface: PixelSurface, frameChange = false): void {
const history = historyFor(id);
history.undo.push(frameOf(surface));
history.undo.push(frameOf(surface, frameChange));
if (history.undo.length > MAX_UNDO) history.undo.shift();
history.redo = [];
}

/** Restore the previous snapshot INTO `surface.data`, at its size. False when there is none. */
/**
* Restore the previous snapshot INTO `surface.data`, at its size, passing
* over a frame change's that would change nothing. False when there is none.
*/
export function undoPixels(id: string, surface: PixelSurface): boolean {
const history = historyFor(id);
const previous = history.undo.pop();
let previous = history.undo.pop();
while (previous?.frameChange && showsTheSame(previous, surface)) previous = history.undo.pop();
if (!previous) return false;
history.redo.push(frameOf(surface));
restore(surface, previous);
Expand Down
9 changes: 8 additions & 1 deletion web/src/lib/lab/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,14 @@ export type LabStore = LabProject & {
setLayerRole: (id: string, role: LayerRole) => void;
setLayerMaskInvert: (id: string, maskInvert: boolean) => void;

updateLayerCode: (id: string, code: string) => void;
/**
* Replace a code layer's code. A changed @matrix line it can read changes
* the frame; with holdMatrix (the Code panel's editor, keystroke by
* keystroke) the line waits for applyMatrixLine.
*/
updateLayerCode: (id: string, code: string, options?: { holdMatrix?: boolean }) => void;
/** Take up the @matrix line the editor held: focus has left it. */
applyMatrixLine: (id: string) => void;
applyCodeToActive: (code: string) => void;
/** Immutable edit of the Director show (timeline authoring). */
updateDirector: (update: (show: DirectorShow) => DirectorShow) => void;
Expand Down
Loading
Loading