Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
07e931b46This file is pinned to the selected release. Download the original to open it in your design tool.
// ── Lab store ────────────────────────────────────────────────────────────────
// One zustand store holds the whole layered project plus the AI gallery. The
// render engine reads it imperatively each frame (labEngine + getState), while
// panels subscribe to the slices they draw. All updates are immutable EXCEPT
// pixel buffers, which are mutated in place and versioned via `rev`.
//
// The actions live in ./store/<slice>.ts, one file per concern, composed here;
// ./store/shared.ts holds the helpers they share. The store's shape (LabStore),
// its persistence subscription and the selectors stay in this file.
import { create } from "zustand";
import type { MatrixSize } from "@/lib/pattern/matrix";
import { saveGallery } from "@/lib/lab/legacyDraft";
import { saveProject } from "./serialize";
import { createAutosave } from "./autosave";
import type { DirectorShow } from "./director/types";
import { isCodeLayer, type BlendMode, type CodeLayer, type EditRef, type ForkRef, type LayerRole, type GalleryItem, type GenJob, type GenSettings, type LabProject, type Layer, type RampState, type RampStopState } from "./types";
import { defaultProject } from "./store/shared";
import { createProjectSlice } from "./store/project";
import { createLayersSlice } from "./store/layers";
import { createKnobsSlice } from "./store/knobs";
import { createRampSlice } from "./store/ramp";
import { createDirectorSlice } from "./store/director";
import { createGallerySlice } from "./store/gallery";
export { codeLayerFromSource } from "./store/shared";
export type LabStore = LabProject & {
hydrated: boolean;
/** Non-null when a previous session was restored — drives the header badge. */
restoredAt: number | null;
/** Browser autosave state; never included in the serialized project. */
saveStatus: "idle" | "pending" | "saved" | "error";
gallery: GalleryItem[];
jobs: GenJob[];
layerErrors: Record<string, string | null>;
/** Selected ramp stop per code layer (UI state, not persisted). */
rampSelection: Record<string, number>;
setRampSelection: (layerId: string, index: number) => void;
hydrate: () => void;
discardProject: () => void;
/** Park the current work in the session ring and empty the canvas. */
stashCurrent: () => boolean;
/** Park a COPY and keep working — used when the canvas is about to become
* the only copy of something, e.g. revising a published pattern. */
parkSnapshot: (title?: string) => boolean;
/** Swap the canvas for a stashed session, parking what is there now. */
restoreSession: (id: string) => boolean;
setMatrix: (matrix: MatrixSize) => void;
setGen: (patch: Partial<GenSettings>) => void;
setForkOf: (forkOf: ForkRef) => void;
setEditOf: (editOf: EditRef) => void;
selectLayer: (id: string) => void;
addCodeLayer: () => void;
addPixelLayer: () => void;
addCodeLayerFromCode: (code: string, name?: string, forkOf?: ForkRef) => void;
/** Restore a shared @stack: its layers land ON TOP of the current stack. */
/** Rename the piece — the identity every hand-off shares (see LabProject.name). */
setName: (name: string) => void;
importStackLayers: (payload: LabProject) => void;
duplicateLayer: (id: string) => void;
removeLayer: (id: string) => void;
reorderLayer: (fromIndex: number, toIndex: number) => void;
renameLayer: (id: string, name: string) => void;
setLayerVisible: (id: string, visible: boolean) => void;
setLayerOpacity: (id: string, opacity: number) => void;
setLayerBlend: (id: string, blend: BlendMode) => void;
setLayerRole: (id: string, role: LayerRole) => void;
setLayerMaskInvert: (id: string, maskInvert: boolean) => void;
updateLayerCode: (id: string, code: string) => void;
applyCodeToActive: (code: string) => void;
/** Immutable edit of the Director show (timeline authoring). */
updateDirector: (update: (show: DirectorShow) => DirectorShow) => void;
setKnob: (index: number, value: number) => void;
setRange: (index: number, edge: "min" | "max", value: number) => void;
setLayerRecolor: (id: string, recolor: boolean) => void;
setLayerRampMode: (id: string, mode: RampState["mode"]) => void;
setLayerRampWrap: (id: string, wrap: boolean) => void;
updateLayerRampStop: (id: string, stopIndex: number, patch: Partial<RampStopState>) => void;
addLayerRampStop: (id: string, stop: RampStopState) => void;
deleteLayerRampStop: (id: string, stopIndex: number) => void;
setLayerRampStops: (id: string, stops: RampStopState[]) => void;
bumpPixelLayer: (id: string) => void;
replacePixelData: (id: string, data: Uint8ClampedArray) => void;
setGallery: (update: (current: GalleryItem[]) => GalleryItem[]) => void;
setJobs: (update: (current: GenJob[]) => GenJob[]) => void;
setLayerErrors: (errors: Record<string, string | null>) => void;
};
export const useLabStore = create<LabStore>((set, get) => ({
...defaultProject(),
hydrated: false,
restoredAt: null,
saveStatus: "idle",
gallery: [],
jobs: [],
layerErrors: {},
rampSelection: {},
...createProjectSlice(set, get),
...createLayersSlice(set, get),
...createKnobsSlice(set),
...createRampSlice(set),
...createDirectorSlice(set),
...createGallerySlice(set),
}));
// ── persistence ──
// Debounced project autosave + gallery persist, gated until hydration so the
// pre-restore default state can never overwrite a stored project.
const autosave = createAutosave(
() => saveProject(useLabStore.getState()),
(saved) => useLabStore.setState({ saveStatus: saved ? "saved" : "error" }),
);
/** Also used by the visible retry action after browser storage recovers. */
export function saveLabProjectNow(): boolean {
if (!useLabStore.getState().hydrated) return false;
autosave.schedule();
return autosave.flush() === true;
}
/** Save queued edits before this workspace unmounts or the page is hidden. */
export function flushLabProject(): void {
autosave.flush();
}
if (typeof window !== "undefined") {
useLabStore.subscribe((state, previous) => {
if (!state.hydrated) return;
if (state.gallery !== previous.gallery) {
saveGallery(state.gallery);
}
const projectChanged =
state.name !== previous.name ||
state.matrix !== previous.matrix ||
state.layers !== previous.layers ||
state.activeLayerId !== previous.activeLayerId ||
state.knobs !== previous.knobs ||
state.ranges !== previous.ranges ||
state.knobLabels !== previous.knobLabels ||
state.forkOf !== previous.forkOf ||
state.editOf !== previous.editOf ||
state.gen !== previous.gen ||
state.director !== previous.director;
if (!projectChanged) return;
// Keep a storage error visible during further edits until a save succeeds.
if (state.saveStatus !== "error") useLabStore.setState({ saveStatus: "pending" });
autosave.schedule();
});
}
// ── selectors ──
export function useActiveLayer(): Layer | undefined {
return useLabStore((state) => state.layers.find((layer) => layer.id === state.activeLayerId));
}
export function useActiveCodeLayer(): CodeLayer | undefined {
const layer = useActiveLayer();
return isCodeLayer(layer) ? layer : undefined;
}
/**
* The code layer whose knobs/ramp the side panels should edit: the active
* layer when it is code, otherwise the topmost code layer.
*/
export function useFocusCodeLayer(): CodeLayer | undefined {
return useLabStore((state) => {
const active = state.layers.find((layer) => layer.id === state.activeLayerId);
if (isCodeLayer(active)) return active;
return state.layers.find(isCodeLayer);
});
}
/**
* THE name of what is being built — the one identity every hand-off shares.
* The hardware export seeds its NAME from this, the module build slugs the
* .pfm from that NAME, and the Director stamps the same name into the show's
* opening pattern cue and its .pfs filename — so a show finds its pattern on
* the device without anyone retyping anything. The project's own name (the
* header input) wins; unnamed projects fall back to the focus code layer,
* then any layer — layer names are parts, but they beat "pattern".
*/
export function labPatternName(
state: Pick<LabStore, "name" | "layers" | "activeLayerId">,
): string {
const project = state.name.trim();
if (project) return project;
const active = state.layers.find((layer) => layer.id === state.activeLayerId);
const layer = isCodeLayer(active) ? active : (state.layers.find(isCodeLayer) ?? state.layers[0]);
const name = layer?.name.trim() ?? "";
return name || "pattern";
}