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
26 changes: 17 additions & 9 deletions web/src/app/pattern-lab/PatternLabClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ 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 { readStorage, removeStorage, writeStorage } from "@/lib/lab/persist";
import { LAYOUT_STORAGE, layoutViewCount } from "@/lib/lab/serialize";
import { readStorage, removeStorage, writeStorage, type LabStorageKey } from "@/lib/lab/persist";
import { LAYOUT_NARROW_STORAGE, LAYOUT_STORAGE, 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 { PANELS, buildDefaultLayout, panelComponents, panelDef, type PanelId } from "./panels/registry";
import { PANELS, buildDefaultLayout, isNarrowDock, panelComponents, panelDef, type PanelId } from "./panels/registry";

import styles from "./PatternLab.module.css";
import dock from "./LabPanels.module.css";
Expand Down Expand Up @@ -143,6 +143,10 @@ export default function PatternLabClient() {
const [workError, setWorkError] = useState<string | null>(null);
const apiRef = useRef<DockviewApi | null>(null);
const layoutSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Where this dock's layout is saved, chosen with the layout itself: a dock
// built narrow keeps saving as narrow after the window widens, until Reset
// layout builds it again for the width it has then.
const layoutKey = useRef<LabStorageKey>(LAYOUT_STORAGE);

const hydrate = useLabStore((state) => state.hydrate);
const restoredAt = useLabStore((state) => state.restoredAt);
Expand Down Expand Up @@ -234,10 +238,12 @@ export default function PatternLabClient() {
(event: DockviewReadyEvent) => {
const api = event.api;
apiRef.current = api;
const narrow = isNarrowDock();
layoutKey.current = narrow ? LAYOUT_NARROW_STORAGE : LAYOUT_STORAGE;

let restored = false;
try {
const raw = readStorage(LAYOUT_STORAGE);
const raw = readStorage(layoutKey.current);
if (raw) {
const parsed = JSON.parse(raw);
// Restoring a layout that places nothing gives you an empty
Expand All @@ -255,12 +261,12 @@ export default function PatternLabClient() {
// Drop the unusable one rather than leaving it to be rejected again on
// every future load — onDidLayoutChange is subscribed below, so
// nothing overwrites it until the first panel is moved.
removeStorage(LAYOUT_STORAGE);
removeStorage(layoutKey.current);
try {
// Whatever a failed fromJSON managed to add is still in there, and
// building the default on top of it collides on panel ids.
api.clear();
buildDefaultLayout(api);
buildDefaultLayout(api, narrow);
} catch {
// A half-built layout is still usable; panels can be opened manually.
}
Expand All @@ -287,7 +293,7 @@ export default function PatternLabClient() {
// taken then is a layout that restores as nothing at all. It is
// never a state worth remembering, so it is never written.
if (layoutViewCount(next) === 0) return;
writeStorage(LAYOUT_STORAGE, JSON.stringify(next));
writeStorage(layoutKey.current, JSON.stringify(next));
} catch {
// Quota/private mode — layout just won't persist.
}
Expand All @@ -312,9 +318,11 @@ export default function PatternLabClient() {
const resetLayout = () => {
const api = apiRef.current;
if (!api) return;
removeStorage(LAYOUT_STORAGE);
const narrow = isNarrowDock();
layoutKey.current = narrow ? LAYOUT_NARROW_STORAGE : LAYOUT_STORAGE;
removeStorage(layoutKey.current);
api.clear();
buildDefaultLayout(api);
buildDefaultLayout(api, narrow);
syncOpenPanels(api);
};

Expand Down
128 changes: 128 additions & 0 deletions web/src/app/pattern-lab/panels/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// The default dock, wide and narrow. At 390px the three columns left each
// panel about 110px, so a narrow dock stacks: Preview on top, the rest as
// tabs under it. The fake below keeps only what the layout code asks of
// dockview: which group a panel lands in, the order of the splits, the size
// it was given and which panel was fronted.

import { afterEach, describe, expect, it, vi } from "vitest";
import type { DockviewApi } from "dockview-react";
import { NARROW_PREVIEW_HEIGHT, PANELS, buildDefaultLayout, isNarrowDock } from "./registry";

type Placement = { id: string; ref: string | null; dir: string | null };

function fakeDock(width: number, height: number) {
const groups: string[][] = [];
const groupOf = new Map<string, string[]>();
const placements: Placement[] = [];
const sizes = new Map<string, { width?: number; height?: number }>();
let active: string | null = null;

const api = {
width,
height,
addPanel(options: { id: string; position?: { referencePanel: string; direction: string } }) {
const ref = options.position?.referencePanel ?? null;
const dir = options.position?.direction ?? null;
if (ref !== null && !groupOf.has(ref)) throw new Error(`${options.id} placed before ${ref}`);
if (dir === "within" && ref !== null) {
const group = groupOf.get(ref)!;
group.push(options.id);
groupOf.set(options.id, group);
} else {
const group = [options.id];
groups.push(group);
groupOf.set(options.id, group);
}
placements.push({ id: options.id, ref, dir });
},
getPanel(id: string) {
if (!groupOf.has(id)) return undefined;
return {
api: {
setActive: () => {
active = id;
},
setSize: (size: { width?: number; height?: number }) => {
sizes.set(id, { ...sizes.get(id), ...size });
},
},
};
},
};

return { api: api as unknown as DockviewApi, groups, placements, sizes, active: () => active };
}

function stubWidth(width: number) {
vi.stubGlobal("matchMedia", (query: string) => {
const max = /max-width:\s*(\d+)px/.exec(query);
return { matches: max ? width <= Number(max[1]) : false, media: query };
});
}

afterEach(() => {
vi.unstubAllGlobals();
});

describe("narrow default layout", () => {
it("puts Preview alone on top and every other panel in one group below it, Code first", () => {
const dock = fakeDock(374, 723);
buildDefaultLayout(dock.api, true);

expect(dock.groups).toEqual([
["preview"],
["code", ...PANELS.filter((def) => def.id !== "preview" && def.id !== "code").map((def) => def.id)],
]);
expect(dock.groups[1].slice(0, 3)).toEqual(["code", "layers", "pixel"]);
// One split, and it is a row: nothing sits beside Preview.
expect(dock.placements.filter((entry) => entry.dir !== "within" && entry.ref !== null)).toEqual([
{ id: "code", ref: "preview", dir: "below" },
]);
});

it("fronts Code and gives Preview its share of the height", () => {
const dock = fakeDock(374, 723);
buildDefaultLayout(dock.api, true);

expect(dock.active()).toBe("code");
expect(dock.sizes.get("preview")).toEqual({ height: Math.round(723 * NARROW_PREVIEW_HEIGHT) });
// The column widths are the wide layout's; a narrow dock sets none.
expect([...dock.sizes.values()].some((size) => size.width !== undefined)).toBe(false);
});
});

describe("wide default layout", () => {
it("places each panel where its registry entry says, columns before rows", () => {
const dock = fakeDock(1424, 819);
buildDefaultLayout(dock.api);

expect(dock.placements.map((entry) => entry.id).sort()).toEqual(PANELS.map((def) => def.id).sort());
for (const def of PANELS) {
const placed = dock.placements.find((entry) => entry.id === def.id);
expect(placed).toEqual({ id: def.id, ref: def.dock?.ref ?? null, dir: def.dock?.dir ?? null });
}
const firstRow = dock.placements.findIndex((entry) => entry.dir === "below");
const lastColumn = dock.placements.map((entry) => entry.dir).lastIndexOf("right");
expect(lastColumn).toBeLessThan(firstRow);
expect(dock.groups).toHaveLength(5);
expect(dock.active()).toBe("code");
});
});

describe("isNarrowDock", () => {
it("is true on a 390px phone and false on a 1440px desktop", () => {
stubWidth(390);
expect(isNarrowDock()).toBe(true);
stubWidth(800);
expect(isNarrowDock()).toBe(true);
stubWidth(801);
expect(isNarrowDock()).toBe(false);
stubWidth(1440);
expect(isNarrowDock()).toBe(false);
});

it("is false where the browser has no matchMedia", () => {
vi.stubGlobal("matchMedia", undefined);
expect(isNarrowDock()).toBe(false);
});
});
59 changes: 58 additions & 1 deletion web/src/app/pattern-lab/panels/registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,24 @@ export const panelComponents: Record<string, FunctionComponent<IDockviewPanelPro
PANELS.map((entry) => [entry.id, entry.component as FunctionComponent<IDockviewPanelProps>]),
);

/**
* At this width the default layout stacks instead of splitting into columns.
* Three columns on a 390px phone left each panel about 110px: the frame
* picker read "128 × 64 (Patter" and the Knobs range fields ran off the
* edge. 800px is where the header above the dock folds to two rows
* (LabPanels.module.css).
*/
export const NARROW_DOCK_QUERY = "(max-width: 800px)";

/** Asked once, when the dock is built. Turning a phone does not re-lay it; Reset layout does. */
export function isNarrowDock(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia(NARROW_DOCK_QUERY).matches;
}

/** Share of a narrow dock's height that Preview keeps at the top: a 2:1 frame at full width, and its bar. */
export const NARROW_PREVIEW_HEIGHT = 0.42;

/**
* Lay every panel out from scratch, in an order that satisfies the references
* so the list above can stay in menu order.
Expand All @@ -107,8 +125,14 @@ export const panelComponents: Record<string, FunctionComponent<IDockviewPanelPro
* relative to the grid as it stands, so "layers below preview" issued while
* preview is the only column becomes a full-width strip at root level — the
* layout this replaced had exactly that comment above its hand-ordered calls.
*
* A narrow dock ignores `dock` and `size`: see buildNarrowLayout.
*/
export function buildDefaultLayout(api: DockviewApi) {
export function buildDefaultLayout(api: DockviewApi, narrow = false) {
if (narrow) {
buildNarrowLayout(api);
return;
}
const placed = new Set<PanelId>();
const pending = [...PANELS];
const place = (def: PanelDef) => {
Expand Down Expand Up @@ -146,3 +170,36 @@ export function buildDefaultLayout(api: DockviewApi) {
if (def.size.height !== undefined) panel.api.setSize({ height: Math.round(height * def.size.height) });
}
}

/**
* The phone layout: the root panel (Preview) across the top, and every other
* panel a tab in one group below it. Each panel gets the full width, and the
* frame stays in sight while Code or Knobs is in front.
*
* The fronted panel is the first tab and the rest follow in menu order. In
* menu order Layers came first, and a 374px tab strip scrolled it out of
* sight to the left of Code.
*/
function buildNarrowLayout(api: DockviewApi) {
const root = PANELS.find((entry) => entry.dock === null);
if (!root) return;
const active = PANELS.find((entry) => entry.active && entry !== root);
const tabs = [...(active ? [active] : []), ...PANELS.filter((entry) => entry !== root && entry !== active)];
api.addPanel({ id: root.id, component: root.id, title: root.title });
let stack: PanelId | null = null;
for (const def of tabs) {
api.addPanel({
id: def.id,
component: def.id,
title: def.title,
position: stack
? { referencePanel: stack, direction: "within" }
: { referencePanel: root.id, direction: "below" },
});
stack ??= def.id;
}
if (active) api.getPanel(active.id)?.api.setActive();

const height = api.height || 700;
api.getPanel(root.id)?.api.setSize({ height: Math.round(height * NARROW_PREVIEW_HEIGHT) });
}
8 changes: 8 additions & 0 deletions web/src/lib/lab/persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ export const LAB_STORAGE = {
project: "patternflow_lab_project_v2",
/** The dockview layout. PatternLabClient.tsx */
layout: "patternflow_lab_layout_v1",
/**
* The dockview layout when the lab opens 800px wide or less. A tablet can
* open the lab upright one day and on its side the next, and each width
* keeps the dock it was arranged at. Written since 16 October 2026; before
* that a phone saved its three squeezed columns under _layout_v1, which a
* narrow screen no longer reads. PatternLabClient.tsx
*/
layoutNarrow: "patternflow_lab_layout_narrow_v1",
/** Ring of parked sessions, newest first. sessions.ts */
sessions: "patternflow_lab_sessions_v1",
/** Graphic Export output settings. capture/settings.ts */
Expand Down
1 change: 1 addition & 0 deletions web/src/lib/lab/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {

export const PROJECT_STORAGE = LAB_STORAGE.project;
export const LAYOUT_STORAGE = LAB_STORAGE.layout;
export const LAYOUT_NARROW_STORAGE = LAB_STORAGE.layoutNarrow;

/**
* How many panels a serialized dockview layout actually PLACES.
Expand Down
Loading