diff --git a/web/src/app/pattern-lab/PatternLabClient.tsx b/web/src/app/pattern-lab/PatternLabClient.tsx index ebbd83ee..5b7372e7 100644 --- a/web/src/app/pattern-lab/PatternLabClient.tsx +++ b/web/src/app/pattern-lab/PatternLabClient.tsx @@ -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"; @@ -143,6 +143,10 @@ export default function PatternLabClient() { const [workError, setWorkError] = useState(null); const apiRef = useRef(null); const layoutSaveTimer = useRef | 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(LAYOUT_STORAGE); const hydrate = useLabStore((state) => state.hydrate); const restoredAt = useLabStore((state) => state.restoredAt); @@ -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 @@ -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. } @@ -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. } @@ -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); }; diff --git a/web/src/app/pattern-lab/panels/registry.test.ts b/web/src/app/pattern-lab/panels/registry.test.ts new file mode 100644 index 00000000..9f81cf54 --- /dev/null +++ b/web/src/app/pattern-lab/panels/registry.test.ts @@ -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(); + const placements: Placement[] = []; + const sizes = new Map(); + 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); + }); +}); diff --git a/web/src/app/pattern-lab/panels/registry.tsx b/web/src/app/pattern-lab/panels/registry.tsx index 005fc3fc..a9d23a68 100644 --- a/web/src/app/pattern-lab/panels/registry.tsx +++ b/web/src/app/pattern-lab/panels/registry.tsx @@ -97,6 +97,24 @@ export const panelComponents: Record [entry.id, entry.component as FunctionComponent]), ); +/** + * 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. @@ -107,8 +125,14 @@ export const panelComponents: Record(); const pending = [...PANELS]; const place = (def: PanelDef) => { @@ -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) }); +} diff --git a/web/src/lib/lab/persist.ts b/web/src/lib/lab/persist.ts index 3157fe48..8f224376 100644 --- a/web/src/lib/lab/persist.ts +++ b/web/src/lib/lab/persist.ts @@ -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 */ diff --git a/web/src/lib/lab/serialize.ts b/web/src/lib/lab/serialize.ts index b09dbd24..c27946ed 100644 --- a/web/src/lib/lab/serialize.ts +++ b/web/src/lib/lab/serialize.ts @@ -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.