diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts new file mode 100644 index 0000000000..38b02f58e6 --- /dev/null +++ b/packages/cli/src/commands/preview.test.ts @@ -0,0 +1,53 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { studioLandingSearch } from "./preview.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function projectWith(storyboard: string | null, frameFiles: string[] = []): string { + const dir = mkdtempSync(join(tmpdir(), "hf-preview-landing-")); + tempDirs.push(dir); + if (storyboard !== null) writeFileSync(join(dir, "STORYBOARD.md"), storyboard); + for (const file of frameFiles) { + mkdirSync(join(dir, file, ".."), { recursive: true }); + writeFileSync(join(dir, file), "
"); + } + return dir; +} + +const FRAME = (n: number, status: string) => + `## Frame ${n} — F${n}\n- status: ${status}\n- src: compositions/frames/0${n}.html\n\nBeat.\n`; + +describe("studioLandingSearch", () => { + it("returns no search without a storyboard", () => { + expect(studioLandingSearch(projectWith(null))).toBe(""); + }); + + it("lands on the board while sketches are under review (any built frame)", () => { + const dir = projectWith(`${FRAME(1, "built")}${FRAME(2, "outline")}`, [ + "compositions/frames/01.html", + ]); + expect(studioLandingSearch(dir)).toBe("?view=storyboard"); + }); + + it("lands on the board during pure planning (srcs declared, none exist)", () => { + const dir = projectWith(`${FRAME(1, "outline")}${FRAME(2, "outline")}`); + expect(studioLandingSearch(dir)).toBe("?view=storyboard"); + }); + + it("lands on the timeline once frames exist without a built status", () => { + const dir = projectWith(`${FRAME(1, "outline")}`, ["compositions/frames/01.html"]); + expect(studioLandingSearch(dir)).toBe(""); + }); + + it("lands on the timeline for fully animated boards", () => { + const dir = projectWith(`${FRAME(1, "animated")}`, ["compositions/frames/01.html"]); + expect(studioLandingSearch(dir)).toBe(""); + }); +}); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 598d6e92e9..0b11a65e82 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -19,7 +19,16 @@ export const examples: Example[] = [ ["List all active preview servers", "hyperframes preview --list"], ["Kill all active preview servers", "hyperframes preview --kill-all"], ]; -import { existsSync, lstatSync, symlinkSync, unlinkSync, readlinkSync, mkdirSync } from "node:fs"; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + symlinkSync, + unlinkSync, +} from "node:fs"; +import { parseStoryboard, STORYBOARD_FILENAME } from "@hyperframes/core/storyboard"; import { resolve, dirname, basename, join } from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; @@ -635,9 +644,47 @@ function compactSelectionPayload(selection: StudioSelectionSnapshot): CompactSel }; } -function openStudioBrowser(url: string, projectName: string, options?: BrowserLaunchOptions): void { +// Land the browser on the Storyboard view while the project is still planning +// or sketching — the timeline only becomes the right landing once frames are +// animated (or the storyboard never tracked statuses at all, e.g. beat plans). +export function studioLandingSearch(projectDir: string): string { + const storyboardPath = join(projectDir, STORYBOARD_FILENAME); + if (!existsSync(storyboardPath)) return ""; + let frames; + try { + frames = parseStoryboard(readFileSync(storyboardPath, "utf8")).frames; + } catch { + return ""; + } + // Sketch review in progress — the board is the review surface. + if (frames.some((f) => f.status === "built")) return "?view=storyboard"; + // Pure planning stage: frames declare src paths but none are built yet. + const srcs = frames + .map((f) => f.src) + .filter((s): s is string => typeof s === "string" && s.length > 0); + const planning = + frames.length > 0 && + frames.every((f) => f.status === "outline") && + srcs.length > 0 && + !srcs.some((s) => existsSync(join(projectDir, s))); + return planning ? "?view=storyboard" : ""; +} + +// The full Studio URL to open or hand to the user: status-aware landing view +// plus the project hash route. `url` never carries a trailing slash (both the +// embedded server and the Vite `Local:` match strip it). +function studioDeepLink(url: string, projectName: string, projectDir: string): string { + return `${url}/${studioLandingSearch(projectDir)}#project/${projectName}`; +} + +function openStudioBrowser( + url: string, + projectName: string, + projectDir: string, + options?: BrowserLaunchOptions, +): void { if (options?.noOpen) return; - openBrowser(`${url}#project/${projectName}`, { + openBrowser(studioDeepLink(url, projectName, projectDir), { browserPath: options?.browserPath, userDataDir: options?.userDataDir, remoteDebuggingPort: options?.remoteDebuggingPort, @@ -719,6 +766,7 @@ function attachStudioReadyHandler( child: StudioChildProcess, spinner: ReturnType