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
5 changes: 3 additions & 2 deletions web/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,13 @@ Mirrors the firmware; the same JS runs in the browser, in the community's sandbo

## Pattern Lab (`src/app/pattern-lab/` + `src/lib/lab/`)

A layered, dockable workspace: eight panels over a layer-stack project. A panel is one entry in `panels/registry.tsx` (id, title, component, where it docks by default) plus its component file — the shell derives the dockview component map, the Panels menu and the default layout from that list, and the three heavy editors load on demand. The project is one Zustand store, `src/lib/lab/store.ts`, which the render engine (`engine.ts`) reads imperatively each frame and the panels subscribe to; its actions are composed from one slice per concern under `src/lib/lab/store/` (project, layers, knobs, ramp, director, gallery), with the helpers they share in `store/shared.ts`. Everything the lab keeps in `localStorage` is listed in `src/lib/lab/persist.ts`, with the read/write helpers every persistence site uses. Pixel undo stacks are `src/lib/lab/pixelHistory.ts`.
A layered, dockable workspace: nine panels over a layer-stack project. A panel is one entry in `panels/registry.tsx` (id, title, component, where it docks by default) plus its component file — the shell derives the dockview component map, the Panels menu and the default layout from that list, and the four heavy editors load on demand. The project is one Zustand store, `src/lib/lab/store.ts`, which the render engine (`engine.ts`) reads imperatively each frame and the panels subscribe to; its actions are composed from one slice per concern under `src/lib/lab/store/` (project, layers, text, knobs, ramp, director), with the helpers they share in `store/shared.ts`. Everything the lab keeps in `localStorage` is listed in `src/lib/lab/persist.ts`, with the read/write helpers every persistence site uses. Pixel undo stacks are `src/lib/lab/pixelHistory.ts`.

| Panel | Where | What |
| :--- | :--- | :--- |
| Preview, Layers, Code, Knobs, Color Ramp | `panels/` | The editor proper. Layers are code (JS) or pixel (RGBA buffer, edited in place and versioned by `rev`); ramps are per layer, knobs are one shared set |
| Preview, Layers, Code, Knobs, Color Ramp | `panels/` | The editor proper. Layers are code (JS), pixel (RGBA buffer, edited in place and versioned by `rev`) or text; ramps are per code or text layer, knobs are one shared set |
| Pixel | `panels/PixelPanel.tsx` + `panels/pixel/` + `lib/lab/pixelTools.ts` | Pixel-art editor. The panel composes one hook per concern (`usePixelToolState`, `usePixelSelection`, `usePixelViewport`, `usePixelCanvases`, `usePixelPointer`, `usePixelKeyboard`) and two toolbar components; `pixelToCode.ts` turns a sprite into standalone pattern code |
| Text | `panels/TextPanel.tsx` + `lib/lab/text.ts`, `textRaster.ts`, `textFonts.ts` | Text layers. Words, size, place, alignment and font are drawn into the same RGBA buffer a pixel layer holds on every change, so the engine, masks, Graphic Export and both exports read a bitmap and never the font. Fonts are the site's own, the device's generic families, or a file the person picks, read with `FontFace` and kept in this browser's IndexedDB (`patternflow_lab_fonts_v1`) only when they ask. Drag on Preview moves the selected text |
| Graphic Export | `panels/CapturePanel.tsx` + `lib/lab/capture/` | Stills and clips at print sizes, rendered by a second engine in a Web Worker (WebCodecs + `mediabunny`, all client-side), plus a GLSL shader twin for posters. `probe.ts` decides whether a pattern can be re-rendered at another size or must be upscaled |
| Director | `panels/DirectorPanel.tsx` + `panels/director/` + `lib/lab/director/` | Knob automation over time — keyframes, bezier eases — baked to `.pfs` show files (`src/lib/pattern/pfst.ts` is the codec) and exported as MIDI. The panel composes `useTransportView`, `useTimelineZoom`, `useShowEditing`, the file helpers and two bars; the lanes are `DirectorLanes.tsx` |

Expand Down
25 changes: 24 additions & 1 deletion web/src/app/pattern-lab/panels/LayersPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// asks first; a blank one goes straight away.

import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import LayersPanel from "./LayersPanel";
import { useLabStore } from "@/lib/lab/store";

Expand Down Expand Up @@ -79,3 +79,26 @@ describe("LayersPanel Del", () => {
expect(names()).toEqual(["Pixel 1", "Code 1"]);
});
});

describe("LayersPanel + Text", () => {
it("adds a text layer on top, marked TX", () => {
render(<LayersPanel />);
fireEvent.click(screen.getByRole("button", { name: "+ Text" }));
expect(names()).toEqual(["Text 1", "Code 1"]);
expect(screen.getByText("TX")).toBeInTheDocument();
});

it("deletes a text layer that says nothing without asking, and asks for one that says something", () => {
store().addTextLayer();
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
render(<LayersPanel />);
fireEvent.click(del());
expect(confirm).toHaveBeenCalledOnce();
expect(names()).toEqual(["Text 1", "Code 1"]);

act(() => store().setTextSpec(store().layers[0].id, { text: " " }));
fireEvent.click(del());
expect(confirm).toHaveBeenCalledOnce();
expect(names()).toEqual(["Code 1"]);
});
});
23 changes: 19 additions & 4 deletions web/src/app/pattern-lab/panels/LayersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,28 @@ import { useLabStore } from "@/lib/lab/store";
import { STARTER_CODE } from "@/lib/lab/store/shared";
import dock from "../LabPanels.module.css";

// A pixel layer nobody has drawn on, or a code layer still holding the
// starter pattern, has nothing to lose, so Del takes it without asking.
// A pixel layer nobody has drawn on, a code layer still holding the starter
// pattern, or a text layer that still says nothing, has nothing to lose, so
// Del takes it without asking.
function holdsWork(layer: Layer): boolean {
if (layer.type === "code") return layer.code !== STARTER_CODE;
if (layer.type === "text") return layer.spec.text.trim() !== "";
for (let i = 3; i < layer.data.length; i += 4) {
if (layer.data[i] !== 0) return true;
}
return false;
}

const LAYER_BADGE: Record<Layer["type"], string> = { code: "JS", pixel: "PX", text: "TX" };

export default function LayersPanel() {
const layers = useLabStore((state) => state.layers);
const activeLayerId = useLabStore((state) => state.activeLayerId);
const layerErrors = useLabStore((state) => state.layerErrors);
const selectLayer = useLabStore((state) => state.selectLayer);
const addCodeLayer = useLabStore((state) => state.addCodeLayer);
const addPixelLayer = useLabStore((state) => state.addPixelLayer);
const addTextLayer = useLabStore((state) => state.addTextLayer);
const duplicateLayer = useLabStore((state) => state.duplicateLayer);
const removeLayer = useLabStore((state) => state.removeLayer);
const reorderLayer = useLabStore((state) => state.reorderLayer);
Expand Down Expand Up @@ -73,6 +78,16 @@ export default function LayersPanel() {
>
+ Pixel
</button>
<button
type="button"
onClick={() => {
addTextLayer();
revealEditor("text", { open: true });
}}
title="Add a text layer, in a font from your own device if you like"
>
+ Text
</button>
<span style={{ flex: 1 }} />
<button
type="button"
Expand Down Expand Up @@ -203,7 +218,7 @@ export default function LayersPanel() {
</span>
)
)}
<span className={dock.layerType}>{layer.type === "code" ? "JS" : "PX"}</span>
<span className={dock.layerType}>{LAYER_BADGE[layer.type]}</span>
</div>
</li>
);
Expand All @@ -217,7 +232,7 @@ export default function LayersPanel() {
<span>Mask</span>
<label
className={dock.layerMaskLabel}
title="Turn this layer into a mask for the layer directly below it. Pixel art: drawn pixels reveal. Code: bright output (past 0.5, ramp-tunable) reveals."
title="Turn this layer into a mask for the layer directly below it. Pixel art and text: drawn pixels reveal. Code: bright output (past 0.5, ramp-tunable) reveals."
>
<input
type="checkbox"
Expand Down
56 changes: 54 additions & 2 deletions web/src/app/pattern-lab/panels/PreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@
// This hook into lib/lab/capture/session is the one place the lab touches
// the capture add-on; removing capture means removing this and the lab is
// whole again.
//
// With a text layer selected, a drag on the frame moves the text: mouse,
// pen or a finger, anywhere on the frame, in whole LED pixels.

import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import {
MATRIX_HEAVY_PIXELS,
MATRIX_MAX,
Expand Down Expand Up @@ -63,6 +66,47 @@ export default function PreviewPanel() {
const activeError = useLabStore(
(state) => state.layerErrors[state.activeLayerId] ?? null,
);
const textLayerId = useLabStore((state) => {
const active = state.layers.find((layer) => layer.id === state.activeLayerId);
return active?.type === "text" && active.visible ? active.id : null;
});
const textDrag = useRef<{ id: string; pointerId: number; clientX: number; clientY: number; x: number; y: number; scale: number } | null>(null);

const startTextDrag = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (!textLayerId || event.button !== 0) return;
const layer = useLabStore.getState().layers.find((entry) => entry.id === textLayerId);
if (layer?.type !== "text") return;
const rect = event.currentTarget.getBoundingClientRect();
// object-fit: contain — the frame is drawn at the smaller of the two scales.
const scale = Math.min(rect.width / layer.width, rect.height / layer.height);
if (!(scale > 0)) return;
event.preventDefault();
event.currentTarget.setPointerCapture?.(event.pointerId);
textDrag.current = {
id: layer.id,
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
x: layer.spec.x,
y: layer.spec.y,
scale,
};
};

const moveTextDrag = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const drag = textDrag.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const state = useLabStore.getState();
const layer = state.layers.find((entry) => entry.id === drag.id);
if (layer?.type !== "text") return;
const x = Math.round(drag.x + (event.clientX - drag.clientX) / drag.scale);
const y = Math.round(drag.y + (event.clientY - drag.clientY) / drag.scale);
if (x !== layer.spec.x || y !== layer.spec.y) state.setTextSpec(drag.id, { x, y });
};

const endTextDrag = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (textDrag.current?.pointerId === event.pointerId) textDrag.current = null;
};

const resPreset = `${matrix.width}x${matrix.height}`;
const isPresetMatrix = RESOLUTION_PRESETS.some((preset) => preset.value === resPreset);
Expand Down Expand Up @@ -262,8 +306,16 @@ export default function PreviewPanel() {
ref={canvasRef}
width={matrix.width}
height={matrix.height}
style={{ aspectRatio: `${matrix.width} / ${matrix.height}` }}
style={{
aspectRatio: `${matrix.width} / ${matrix.height}`,
...(textLayerId ? { cursor: "move", touchAction: "none" } : {}),
}}
aria-label={`Composite preview, ${formatMatrix(matrix)}`}
title={textLayerId ? "Drag to move the selected text" : undefined}
onPointerDown={textLayerId ? startTextDrag : undefined}
onPointerMove={textLayerId ? moveTextDrag : undefined}
onPointerUp={textLayerId ? endTextDrag : undefined}
onPointerCancel={textLayerId ? endTextDrag : undefined}
/>
)}
</div>
Expand Down
16 changes: 16 additions & 0 deletions web/src/app/pattern-lab/panels/RampPanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,22 @@
text-transform: none;
}

.rampHint button {
min-height: 28px;
border-radius: 4px;
border: 1px solid rgba(23, 21, 18, 0.3);
background: #fffdf8;
color: #171512;
font: inherit;
padding: 2px 8px;
cursor: pointer;
}

.rampHint button:hover {
background: #171512;
color: #f7f3ea;
}

@container ramp-controls (max-width: 210px) {
.paletteActions {
display: flex;
Expand Down
53 changes: 34 additions & 19 deletions web/src/app/pattern-lab/panels/RampPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
// Color ramp for the focused code layer — chained to the code, so every code
// layer keeps its own ramp. New in the layered lab: per-stop ALPHA, drawn over
// a checkerboard, which is what lets a value field fade out and reveal the
// layers underneath.
// layers underneath. A selected text layer has a ramp too: its letters take
// these colours across or down when the Text panel's fill says so.

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Expand All @@ -16,8 +17,8 @@ import {
} from "@/lib/pattern/harness";
import { codeUsesValueField } from "@/lib/pattern/ramp";
import { rampStateToHarness } from "@/lib/lab/engine";
import { DEFAULT_RAMP_STATE, type CodeLayer } from "@/lib/lab/types";
import { useFocusCodeLayer, useLabStore } from "@/lib/lab/store";
import { DEFAULT_RAMP_STATE, type RampLayer } from "@/lib/lab/types";
import { useFocusRampLayer, useLabStore } from "@/lib/lab/store";
import { rgbToHex } from "@/lib/pattern/color";
import { randomRampColors } from "@/lib/lab/rampPalettes";
import { useRampHistory } from "./ramp/useRampHistory";
Expand All @@ -39,7 +40,7 @@ const LUMA_TREND_LABEL: Record<LumaTrend, string> = {
};

export default function RampPanel() {
const layer = useFocusCodeLayer();
const layer = useFocusRampLayer();
const addCodeLayer = useLabStore((state) => state.addCodeLayer);
if (!layer) {
return (
Expand All @@ -55,14 +56,15 @@ export default function RampPanel() {
return <RampEditor key={layer.id} layer={layer} />;
}

function RampEditor({ layer }: { layer: CodeLayer }) {
function RampEditor({ layer }: { layer: RampLayer }) {
const setLayerRampMode = useLabStore((state) => state.setLayerRampMode);
const setLayerRampWrap = useLabStore((state) => state.setLayerRampWrap);
const setLayerRecolor = useLabStore((state) => state.setLayerRecolor);
const updateLayerRampStop = useLabStore((state) => state.updateLayerRampStop);
const addLayerRampStop = useLabStore((state) => state.addLayerRampStop);
const deleteLayerRampStop = useLabStore((state) => state.deleteLayerRampStop);
const setLayerRampStops = useLabStore((state) => state.setLayerRampStops);
const setTextSpec = useLabStore((state) => state.setTextSpec);
const selectedStopIndex = useLabStore((state) => state.rampSelection[layer.id] ?? 0);
const setRampSelection = useLabStore((state) => state.setRampSelection);
const { edit, beginGesture, endGesture, undo, redo, canUndo, canRedo } = useRampHistory(layer.id);
Expand Down Expand Up @@ -198,7 +200,7 @@ function RampEditor({ layer }: { layer: CodeLayer }) {

const activeStopIndex = Math.min(selectedStopIndex, ramp.stops.length - 1);
const activeStop = ramp.stops[activeStopIndex];
const usesValueField = codeUsesValueField(layer.code);
const usesValueField = layer.type === "code" && codeUsesValueField(layer.code);

const addStopAt = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
Expand Down Expand Up @@ -232,7 +234,7 @@ function RampEditor({ layer }: { layer: CodeLayer }) {
return (
<div className={dock.panel}>
<div className={dock.panelBar}>
<label title="Ramp is chained to this code layer">{layer.name}</label>
<label title={`Ramp is chained to this ${layer.type} layer`}>{layer.name}</label>
<span style={{ flex: 1 }} />
<select
value={ramp.mode}
Expand All @@ -257,17 +259,19 @@ function RampEditor({ layer }: { layer: CodeLayer }) {
/>
wrap
</label>
<label
className={local.rampToggle}
title="Recolor this layer: map each drawn pixel's luminance through the ramp (works on RGB patterns too)"
>
<input
type="checkbox"
checked={layer.recolor}
onChange={(event) => edit(() => setLayerRecolor(layer.id, event.target.checked))}
/>
recolor
</label>
{layer.type === "code" && (
<label
className={local.rampToggle}
title="Recolor this layer: map each drawn pixel's luminance through the ramp (works on RGB patterns too)"
>
<input
type="checkbox"
checked={layer.recolor}
onChange={(event) => edit(() => setLayerRecolor(layer.id, event.target.checked))}
/>
recolor
</label>
)}
</div>

<div className={`${dock.panelBody} ${dock.rampDock}`}>
Expand Down Expand Up @@ -425,12 +429,23 @@ function RampEditor({ layer }: { layer: CodeLayer }) {
</>
)}

{!usesValueField && !layer.recolor && (
{layer.type === "code" && !usesValueField && !layer.recolor && (
<p className={local.rampHint}>
This layer draws RGB directly — the ramp applies when the code uses display.setValue,
or when recolor is on.
</p>
)}
{layer.type === "text" && layer.spec.fill === "solid" && (
<p className={local.rampHint}>
This text is one colour. Fill it with the ramp:{" "}
<button type="button" onClick={() => setTextSpec(layer.id, { fill: "rampX" })}>
across
</button>{" "}
<button type="button" onClick={() => setTextSpec(layer.id, { fill: "rampY" })}>
down
</button>
</p>
)}
</div>
</div>
);
Expand Down
Loading
Loading