feat(studio,cli): add an Edit with Framey button and hyperframes open for HyperFrames Studio

Point people from the CLI and the Studio preview to HyperFrames Studio, the desktop app.

- Studio header: an "Edit with Framey" button with Framey, the app's cursor mascot (the eye
  follows the pointer, a wiggle on hover, honours reduced motion). Until the app opens
  handed-over projects (HANDOFF_READY), a press shows a "Meet Framey" card with the download.
  The button shows only where the preview server answers GET /api/open-in-desktop on macOS,
  so the desktop app's own embedded Studio never shows it.
- `hyperframes open [dir]`: hands the project folder to the app with `open -b` (released app,
  then Canary) and points to the download when neither is installed. When run by Claude Code
  or Codex it leaves .hyperframes/agent-handoff.json (CLAUDE_CODE_SESSION_ID / CODEX_THREAD_ID)
  so the app's first chat can pick up that conversation.
- render and preview print one line about the app: the download until HANDOFF_READY, then
  `hyperframes open` when the app is installed. Nothing for a draft render, a batch row, or a
  run inside the app (HYPERFRAMES_DESKTOP_PROJECT).
This commit is contained in:
Miao Yang
2026-10-03 22:25:02 +08:00
parent ce08f2042e
commit 58934a34b8
16 changed files with 722 additions and 5 deletions
+1
View File
@@ -152,6 +152,7 @@ const commandLoaders = {
preview: () =>
assertStudioWorkspaceBuilt().then(() => import("./commands/preview.js").then((m) => m.default)),
publish: () => import("./commands/publish.js").then((m) => m.default),
open: () => import("./commands/open.js").then((m) => m.default),
render: () => import("./commands/render.js").then((m) => m.default),
lint: () => import("./commands/lint.js").then((m) => m.default),
check: () => import("./commands/check.js").then((m) => m.default),
+45
View File
@@ -0,0 +1,45 @@
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { openInDesktop } from "../utils/desktopApp.js";
import { resolveProject } from "../utils/project.js";
export const examples: Example[] = [
["Open this project in HyperFrames Studio", "hyperframes open"],
["Open another project", "hyperframes open ./my-video"],
["For agents", "hyperframes open --json"],
];
export default defineCommand({
meta: { name: "open", description: "Open a project in HyperFrames Studio, the desktop app" },
args: {
dir: {
type: "positional",
description: "Project directory (default: current)",
required: false,
},
json: { type: "boolean", description: "Output as JSON", default: false },
},
run({ args }) {
const project = resolveProject(args.dir);
const result = openInDesktop(project.dir);
if (args.json) {
console.log(JSON.stringify({ project: project.dir, ...result }, null, 2));
return;
}
if (result.opened) {
console.log(`${c.success("◇")} Opening ${c.accent(project.name)} in HyperFrames Studio`);
if (result.handedOver) {
const agent = result.handedOver.engine === "claude" ? "Claude Code" : "Codex";
console.log(` ${c.dim(`Its chat picks up this ${agent} conversation.`)}`);
}
return;
}
console.log(
result.reason === "not-installed"
? `${c.warn("◇")} HyperFrames Studio isn't installed on this Mac.`
: `${c.warn("◇")} Opening a project from the CLI works on macOS only for now.`,
);
console.log(` Download it: ${c.accent(result.downloadUrl)}`);
},
});
+9 -5
View File
@@ -39,6 +39,7 @@ import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { desktopHint } from "../utils/desktopApp.js";
import { isDevMode } from "../utils/env.js";
import { normalizeErrorMessage as errorMessage } from "../utils/errorMessage.js";
import { buildNpxCommand } from "../utils/npxCommand.js";
@@ -365,7 +366,7 @@ export default defineCommand({
);
return;
}
printStudioSummary(project.name, previewBaseUrl(status.port), {
printStudioSummary(project.name, previewBaseUrl(status.port), project.dir, {
details: [`Background preview running (PID ${status.pid}).`, `Log: ${status.logPath}`],
});
return;
@@ -539,7 +540,7 @@ export default defineCommand({
);
} else {
clack.intro(c.bold("hyperframes preview"));
printStudioSummary(projectName, url, {
printStudioSummary(projectName, url, dir, {
details: [
background.type === "reused"
? "Reusing the background server already running for this project."
@@ -1282,6 +1283,7 @@ function openStudioBrowser(url: string, projectName: string, options?: BrowserLa
function printStudioSummary(
projectName: string,
serverUrl: string,
projectDir: string,
opts: { details?: string[]; footer?: string } = {},
): void {
const urls = studioSummaryUrls(projectName, serverUrl);
@@ -1289,6 +1291,8 @@ function printStudioSummary(
console.log(` ${c.dim("Project")} ${c.accent(projectName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(urls.studioUrl)}`);
console.log(` ${c.dim("Server")} ${c.accent(urls.serverUrl)}`);
const hint = desktopHint(projectDir);
if (hint) console.log(` ${c.dim("Desktop")} ${hint}`);
console.log();
for (const detail of opts.details ?? []) {
console.log(` ${c.dim(detail)}`);
@@ -1395,7 +1399,7 @@ function attachStudioReadyHandler(
);
} else {
spinner.stop(c.success("Studio running"));
printStudioSummary(projectName, url, {
printStudioSummary(projectName, url, projectDir, {
footer: "Press Ctrl+C to stop",
});
}
@@ -1629,7 +1633,7 @@ async function runEmbeddedMode(
);
} else {
s.stop(c.success("Already running"));
printStudioSummary(pName, url, {
printStudioSummary(pName, url, dir, {
details: ["Reusing existing server. Use --force-new to start a fresh instance."],
});
}
@@ -1647,7 +1651,7 @@ async function runEmbeddedMode(
console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`);
console.log();
}
printStudioSummary(pName, url, {
printStudioSummary(pName, url, dir, {
details: [
"Edit with your AI agent — it has HyperFrames skills installed.",
"Changes reload automatically in the studio.",
+12
View File
@@ -62,6 +62,7 @@ import { resolve, dirname, join, basename } from "node:path";
import { execFileSync, spawn } from "node:child_process";
import { loadProducer } from "../utils/producer.js";
import { c } from "../ui/colors.js";
import { desktopHint } from "../utils/desktopApp.js";
import {
formatBytes,
formatRenderSummaryDetail,
@@ -524,6 +525,8 @@ export interface RenderOptions {
throwOnError?: boolean;
/** Skip the interactive feedback prompt after a successful render. */
skipFeedback?: boolean;
/** False for a batch row: one line about the desktop app per batch is noise, not a pointer. */
desktopHint?: boolean;
/**
* OPT IN to managing the DE parallel-router circuit breaker
* (`applyDeParallelRouterCircuitBreaker`) for this render. Default OFF —
@@ -886,6 +889,8 @@ async function renderDocker(
runPostRenderStep("printRenderComplete", () =>
printRenderComplete({
outputPath,
projectDir,
desktopHint: options.desktopHint !== false && options.quality !== "draft",
elapsedMs: elapsed,
quiet: options.quiet,
format: options.format,
@@ -1138,6 +1143,8 @@ async function executeLocalRender(
runPostRenderStep("printRenderComplete", () =>
printRenderComplete({
outputPath,
projectDir,
desktopHint: options.desktopHint !== false && options.quality !== "draft",
elapsedMs: elapsed,
quiet: options.quiet,
format: options.format,
@@ -1900,6 +1907,9 @@ function readOutputFootprint(outputPath: string): { fileSize: string; isDirector
function printRenderComplete(input: {
outputPath: string;
projectDir: string;
/** Print the desktop-app line: a delivered render, never a draft or a batch row. */
desktopHint: boolean;
elapsedMs: number;
quiet: boolean;
format: RenderFormat;
@@ -1920,6 +1930,8 @@ function printRenderComplete(input: {
console.log(c.success("\u25C7") + " " + c.accent(outputPath));
console.log(" " + c.bold(fileSize) + c.dim(" \u00B7 " + detail));
if (perf) printRenderPipeline(perf, input.requestedGpuMode);
const hint = input.desktopHint ? desktopHint(input.projectDir) : null;
if (hint) console.log(" " + c.dim(hint));
}
function printRenderPipeline(
@@ -365,6 +365,7 @@ async function executeBatchRender(
exitAfterComplete: false,
throwOnError: true,
skipFeedback: true,
desktopHint: false,
manageDeParallelRouterBreaker: plan.batchConcurrency <= 1,
};
const manifest = await batchModule.runBatchRender({
+1
View File
@@ -27,6 +27,7 @@ const GROUPS: Group[] = [
["present", "Open a slideshow deck in presenter mode (with audience sync)"],
["publish", "Upload a project to a stable URL (private by default)"],
["render", "Render a composition to MP4 or WebM"],
["open", "Open a project in HyperFrames Studio, the desktop app"],
],
},
{
+9
View File
@@ -966,6 +966,15 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
});
});
// HyperFrames Studio, the desktop app: the header's "Open in app" asks this server, which runs on the person's Mac,
// to hand the project folder over (`hyperframes open`'s `open -b`). The desktop's own Studio has no such route, so
// the button stays hidden there.
app.get("/api/open-in-desktop", (c) => c.json({ available: process.platform === "darwin" }));
app.post("/api/open-in-desktop", async (c) => {
const { openInDesktop } = await import("../utils/desktopApp.js");
return c.json(openInDesktop(projectDir));
});
// ── Pre-flight checks for render ────────────────────────────────────────
// Intercept render requests before they reach the shared API so we can
// fail fast with an actionable hint instead of burning through the entire
+157
View File
@@ -0,0 +1,157 @@
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
AGENT_HANDOFF_FILE,
DESKTOP_DOWNLOAD_URL,
HANDOFF_READY,
agentSession,
desktopHint,
desktopInstalled,
openCommandFor,
openInDesktop,
} from "./desktopApp.js";
const NO_AGENT = {};
describe("openInDesktop", () => {
it("hands the folder to the released app when it is installed", () => {
const asked: string[] = [];
const result = openInDesktop("/films/a", {
platform: "darwin",
open: (id, dir) => (asked.push(`${id} ${dir}`), true),
env: NO_AGENT,
});
expect(result).toEqual({ opened: true, bundleId: "dev.hyperframes.desktop", handedOver: null });
expect(asked).toEqual(["dev.hyperframes.desktop /films/a"]);
});
it("falls back to Canary when only Canary is installed", () => {
const result = openInDesktop("/films/a", {
platform: "darwin",
open: (id) => id.endsWith(".canary"),
env: NO_AGENT,
});
expect(result).toMatchObject({ opened: true, bundleId: "dev.hyperframes.desktop.canary" });
});
it("points to the download when no app is installed", () => {
const result = openInDesktop("/films/a", { platform: "darwin", open: () => false });
expect(result).toEqual({
opened: false,
reason: "not-installed",
downloadUrl: DESKTOP_DOWNLOAD_URL,
});
});
it("never runs `open` off macOS", () => {
const result = openInDesktop("C:\\films\\a", {
platform: "win32",
open: () => {
throw new Error("must not run");
},
});
expect(result).toMatchObject({ opened: false, reason: "unsupported-platform" });
});
});
describe("agent hand-off", () => {
let dir: string | undefined;
afterEach(() => dir && rmSync(dir, { recursive: true, force: true }));
const handoff = () => join(dir!, AGENT_HANDOFF_FILE);
it("names the conversation the command runs in, Claude Code first", () => {
expect(agentSession({ CLAUDE_CODE_SESSION_ID: "c-1", CODEX_THREAD_ID: "x-1" })).toEqual({
engine: "claude",
sessionId: "c-1",
});
expect(agentSession({ CODEX_THREAD_ID: "x-1" })).toEqual({ engine: "codex", sessionId: "x-1" });
expect(agentSession({})).toBeNull();
});
it("leaves the app the conversation once it has the folder", () => {
dir = mkdtempSync(join(tmpdir(), "hf-handoff-"));
const result = openInDesktop(dir, {
platform: "darwin",
open: () => true,
env: { CLAUDE_CODE_SESSION_ID: "0a8eed95-0869-45bf-83ec-5d71fcc5236c" },
});
expect(result).toMatchObject({ opened: true, handedOver: { engine: "claude" } });
expect(JSON.parse(readFileSync(handoff(), "utf8"))).toEqual({
engine: "claude",
sessionId: "0a8eed95-0869-45bf-83ec-5d71fcc5236c",
});
});
it("writes nothing when no app took the folder, or no agent runs the command", () => {
dir = mkdtempSync(join(tmpdir(), "hf-handoff-"));
openInDesktop(dir, { platform: "darwin", open: () => false, env: { CODEX_THREAD_ID: "x-1" } });
openInDesktop(dir, { platform: "darwin", open: () => true, env: {} });
expect(existsSync(handoff())).toBe(false);
});
});
describe("openCommandFor", () => {
it("names the project relative to where the person is", () => {
expect(openCommandFor("/work/films/a", "/work/films/a")).toBe("hyperframes open .");
expect(openCommandFor("/work/films/a", "/work")).toBe("hyperframes open films/a");
expect(openCommandFor("/work/my film", "/work")).toBe('hyperframes open "my film"');
});
});
describe("desktopInstalled", () => {
const nothing = {
platform: "darwin",
home: "/Users/a",
exists: () => false,
spotlight: () => false,
};
it("finds the app where the DMG and the installer put it, Canary included", () => {
expect(
desktopInstalled({
...nothing,
exists: (p: string) => p === "/Applications/HyperFrames Canary.app",
}),
).toBe(true);
expect(
desktopInstalled({
...nothing,
exists: (p: string) => p === "/Users/a/Applications/HyperFrames.app",
}),
).toBe(true);
});
it("asks Spotlight for one installed anywhere else", () => {
expect(
desktopInstalled({ ...nothing, spotlight: (id: string) => id === "dev.hyperframes.desktop" }),
).toBe(true);
expect(desktopInstalled(nothing)).toBe(false);
});
it("is never there off macOS", () => {
expect(desktopInstalled({ ...nothing, platform: "linux", exists: () => true })).toBe(false);
});
});
describe("desktopHint", () => {
const OUTSIDE = {};
const download = `Edit it by chatting with Framey in HyperFrames Studio → ${DESKTOP_DOWNLOAD_URL}`;
it("points to the download when the app is missing", () => {
expect(desktopHint(process.cwd(), { env: OUTSIDE, installed: false })).toBe(download);
});
it("points an installed app to `hyperframes open` once the app opens handed-over projects", () => {
expect(desktopHint(process.cwd(), { env: OUTSIDE, installed: true })).toBe(
HANDOFF_READY ? "Edit it with Framey: hyperframes open ." : download,
);
});
it("says nothing inside the app, whose runs carry HYPERFRAMES_DESKTOP_PROJECT", () => {
expect(
desktopHint(process.cwd(), { env: { HYPERFRAMES_DESKTOP_PROJECT: "/p" }, installed: true }),
).toBeNull();
});
});
+106
View File
@@ -0,0 +1,106 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join, relative } from "node:path";
// HyperFrames Studio, the desktop app. macOS hands it a folder through `open -b`, which the app takes as a project to
// add to Home and show (hyperframes-internal packages/desktop/main/folderOpen.mjs). The released app first, then Canary.
const DESKTOP_BUNDLE_IDS = ["dev.hyperframes.desktop", "dev.hyperframes.desktop.canary"] as const;
export const DESKTOP_DOWNLOAD_URL = "https://hyperframes.dev/studio/download";
/** The agent conversation a command runs in: Claude Code and Codex name it to the shells they start. */
export interface AgentSession {
engine: "claude" | "codex";
sessionId: string;
}
// The app's first chat in the project picks this conversation up as context, once (hyperframes-internal
// packages/desktop/main/agentHandoffFile.mjs).
export const AGENT_HANDOFF_FILE = join(".hyperframes", "agent-handoff.json");
// ponytail: Claude Code first; a Codex started inside a Claude Code shell (or the reverse) carries both, unsorted.
export function agentSession(env: NodeJS.ProcessEnv = process.env): AgentSession | null {
if (env.CLAUDE_CODE_SESSION_ID)
return { engine: "claude", sessionId: env.CLAUDE_CODE_SESSION_ID };
if (env.CODEX_THREAD_ID) return { engine: "codex", sessionId: env.CODEX_THREAD_ID };
return null;
}
export type DesktopOpenResult =
| { opened: true; bundleId: string; handedOver: AgentSession | null }
| { opened: false; reason: "not-installed" | "unsupported-platform"; downloadUrl: string };
/** Runs `open -b <id> <dir>`; true when macOS found the app and handed it the folder. */
type OpenWithBundle = (bundleId: string, dir: string) => boolean;
const openWithBundle: OpenWithBundle = (bundleId, dir) =>
spawnSync("open", ["-b", bundleId, dir], { stdio: "ignore" }).status === 0;
// ponytail: macOS only; the Windows app needs its own hand-off (argv on second-instance) before this grows a branch.
export function openInDesktop(
dir: string,
{ platform = process.platform, open = openWithBundle, env = process.env } = {},
): DesktopOpenResult {
if (platform !== "darwin")
return { opened: false, reason: "unsupported-platform", downloadUrl: DESKTOP_DOWNLOAD_URL };
const bundleId = DESKTOP_BUNDLE_IDS.find((id) => open(id, dir));
if (!bundleId)
return { opened: false, reason: "not-installed", downloadUrl: DESKTOP_DOWNLOAD_URL };
// Read at the app's first chat send, well after this open, so it is written once the app has the folder.
const handedOver = agentSession(env);
if (handedOver) {
mkdirSync(join(dir, ".hyperframes"), { recursive: true });
writeFileSync(join(dir, AGENT_HANDOFF_FILE), JSON.stringify(handedOver));
}
return { opened: true, bundleId, handedOver };
}
/** The `hyperframes open` line to print for a project, relative to where the person is. */
export function openCommandFor(dir: string, cwd = process.cwd()): string {
const shown = relative(cwd, dir) || ".";
return `hyperframes open ${/\s/.test(shown) ? JSON.stringify(shown) : shown}`;
}
const DESKTOP_APP_NAMES = ["HyperFrames.app", "HyperFrames Canary.app"];
/** Spotlight's copies of an app with this bundle id; an updater's hidden leftover (`.X.app.installing-…`) is none. */
const spotlightFinds = (bundleId: string): boolean =>
spawnSync("mdfind", [`kMDItemCFBundleIdentifier == '${bundleId}'`], { encoding: "utf8" })
.stdout?.split("\n")
.some((path) => path.endsWith(".app") && !basename(path).startsWith(".")) ?? false;
/** Whether this Mac has the desktop app, without launching it: where the DMG and the installer put it, else
* Spotlight, which can miss an app for minutes after an update. Off macOS, never. */
export function desktopInstalled({
platform = process.platform,
home = homedir(),
exists = existsSync,
spotlight = spotlightFinds,
}: {
platform?: string;
home?: string;
exists?: (path: string) => boolean;
spotlight?: (bundleId: string) => boolean;
} = {}): boolean {
if (platform !== "darwin") return false;
const folders = ["/Applications", join(home, "Applications")];
if (folders.some((folder) => DESKTOP_APP_NAMES.some((name) => exists(join(folder, name)))))
return true;
return DESKTOP_BUNDLE_IDS.some(spotlight);
}
// ponytail: the desktop app opens a handed-over folder only from its next release (hyperframes-internal
// main/folderOpen.mjs); until then every hint points to the download, as Studio's button does. Flip with it.
export const HANDOFF_READY = false;
/** The one line render and preview print about the desktop app: open the project in it, or get it. Null inside
* the app itself, whose agent runs carry HYPERFRAMES_DESKTOP_PROJECT. */
export function desktopHint(
dir: string,
{ env = process.env, installed }: { env?: NodeJS.ProcessEnv; installed?: boolean } = {},
): string | null {
if (env.HYPERFRAMES_DESKTOP_PROJECT) return null;
return HANDOFF_READY && (installed ?? desktopInstalled())
? `Edit it with Framey: ${openCommandFor(dir)}`
: `Edit it by chatting with Framey in HyperFrames Studio → ${DESKTOP_DOWNLOAD_URL}`;
}
@@ -0,0 +1,52 @@
import type { Ref } from "react";
// Framey, the desktop app's cursor mascot, as hyperframes-internal packages/nib draws it: a rounded triangle with a
// white rim and one eye. The viewBox frames the body with its rim; the tip points up and left like a cursor.
const BODY = "M77.55 48.44 L122.77 65.85 L99.3 89.41 Z";
const EYE = { cx: 111.6, cy: 68.2, r: 11.5 };
/** `eyeRef` moves the pupils (a translate of a few px) without re-rendering. */
export function FrameyGlyph({
size,
className,
eyeRef,
}: {
size: number;
className?: string;
eyeRef?: Ref<SVGGElement>;
}) {
return (
<svg
width={size}
height={size}
viewBox="58 30 82 76"
aria-hidden="true"
className={className}
overflow="visible"
>
<path
d={BODY}
fill="var(--color-white)"
stroke="var(--color-white)"
strokeWidth={24}
strokeLinejoin="round"
/>
<path
d={BODY}
fill="var(--color-framey)"
stroke="var(--color-framey)"
strokeWidth={19}
strokeLinejoin="round"
/>
<circle cx={EYE.cx} cy={EYE.cy} r={EYE.r} fill="var(--color-white)" />
<g ref={eyeRef} className="hf-framey-eye">
<path
d={`M${EYE.cx - 2.6} ${EYE.cy - 2} V${EYE.cy + 2} M${EYE.cx + 2.6} ${EYE.cy - 2} V${EYE.cy + 2}`}
stroke="var(--color-framey-ink)"
strokeWidth={2.4}
strokeLinecap="round"
/>
</g>
</svg>
);
}
@@ -0,0 +1,115 @@
// @vitest-environment happy-dom
/**
* "Open in app": shown only when the preview server can hand the project to the desktop app; a click opens it, or
* offers the download when the app is missing.
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const showToast = vi.fn();
vi.mock("../contexts/StudioContext", () => ({ useStudioShellContext: () => ({ showToast }) }));
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
const { HANDOFF_READY, OpenInDesktopButton } = await import("./OpenInDesktopButton");
const DOWNLOAD = "https://hyperframes.dev/studio/download";
let mounted: { root: Root; host: HTMLElement } | null = null;
let posts: number;
function serve(get: Response | null, post?: unknown) {
posts = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === "POST") {
posts += 1;
return Response.json(post);
}
return get ?? new Response("Not found", { status: 404 });
}),
);
}
async function mount(): Promise<HTMLElement> {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
mounted = { root, host };
await act(async () => root.render(<OpenInDesktopButton />));
return host;
}
const downloadHref = () =>
[...document.querySelectorAll("a")]
.find((a) => a.textContent?.includes("Get HyperFrames Studio"))
?.getAttribute("href");
const button = () =>
document.querySelector<HTMLButtonElement>('[data-testid="header-open-in-desktop"]');
beforeEach(() => showToast.mockReset());
afterEach(() => {
vi.unstubAllGlobals();
if (!mounted) return;
const { root, host } = mounted;
mounted = null;
act(() => root.unmount());
host.remove();
});
it("stays hidden where the server has no hand-off (the desktop's own Studio)", async () => {
serve(null);
await mount();
expect(button()).toBeNull();
});
it("stays hidden when the server says it cannot open the app (not macOS)", async () => {
serve(Response.json({ available: false }));
await mount();
expect(button()).toBeNull();
});
it("before the app opens projects from here, introduces Framey with the download and opens nothing", async () => {
serve(Response.json({ available: true }), { opened: true });
await mount();
await act(async () => button()!.click());
if (HANDOFF_READY) return;
expect(posts).toBe(0);
expect(document.body.textContent).toContain("Meet Framey");
expect(document.body.textContent).not.toContain("Coming soon");
expect(downloadHref()).toBe(DOWNLOAD);
});
it.skipIf(!HANDOFF_READY)("opens the project in the app and says so", async () => {
serve(Response.json({ available: true }), { opened: true, bundleId: "dev.hyperframes.desktop" });
await mount();
await act(async () => button()!.click());
expect(posts).toBe(1);
expect(showToast).toHaveBeenCalledWith(
"Framey is opening this project in HyperFrames Studio",
"info",
);
expect(button()!.dataset.opening).toBe("true");
expect(document.body.textContent).not.toContain("Meet Framey");
});
it.skipIf(!HANDOFF_READY)(
"introduces Framey and offers the download when the app is missing",
async () => {
serve(Response.json({ available: true }), {
opened: false,
reason: "not-installed",
downloadUrl: DOWNLOAD,
});
await mount();
await act(async () => button()!.click());
expect(showToast).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Meet Framey");
expect(downloadHref()).toBe(DOWNLOAD);
},
);
@@ -0,0 +1,156 @@
import { useEffect, useRef, useState } from "react";
import { useStudioShellContext } from "../contexts/StudioContext";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { FrameyGlyph } from "./FrameyGlyph";
import { Button, buttonBase, buttonSizes, buttonVariants, cn, Popover } from "./ui";
// "Edit with Framey": hands this project to HyperFrames Studio, the desktop app, through the preview server
// (`POST /api/open-in-desktop`, the same hand-off as `hyperframes open`). Shown only when that server says it can.
const ROUTE = "/api/open-in-desktop";
const DOWNLOAD_URL = "https://hyperframes.dev/studio/download";
// ponytail: the desktop app opens a handed-over folder only from its next release; until then the button
// introduces Framey and links the download. Flip once that release ships.
export const HANDOFF_READY = false;
/** How far the pupils travel towards the pointer, in the glyph's own units. */
const GAZE = 2.2;
/** Long enough for the flight to finish before the label comes back. */
const OPENING_MS = 1600;
type OpenResult =
| { opened: true }
| { opened: false; reason: "not-installed" | "unsupported-platform"; downloadUrl: string };
function useDesktopHandoffAvailable(): boolean {
const [available, setAvailable] = useState(false);
useEffect(() => {
let live = true;
fetch(ROUTE)
.then((res) => (res.ok ? res.json() : null))
.then((body: { available?: unknown } | null) => {
if (live) setAvailable(body?.available === true);
})
.catch(() => {});
return () => {
live = false;
};
}, []);
return available;
}
/** Framey looks at the pointer: one listener, one write per frame, no re-render. */
function useGaze(
anchor: React.RefObject<HTMLElement | null>,
eye: React.RefObject<SVGGElement | null>,
) {
useEffect(() => {
let frame = 0;
const look = (event: PointerEvent) => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const box = anchor.current?.getBoundingClientRect();
if (!box || !eye.current) return;
const dx = event.clientX - (box.left + box.height / 2);
const dy = event.clientY - (box.top + box.height / 2);
const distance = Math.hypot(dx, dy) || 1;
eye.current.style.transform = `translate(${(dx / distance) * GAZE}px, ${(dy / distance) * GAZE}px)`;
});
};
window.addEventListener("pointermove", look);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("pointermove", look);
};
}, [anchor, eye]);
}
export function OpenInDesktopButton() {
const { showToast } = useStudioShellContext();
const available = useDesktopHandoffAvailable();
const [opening, setOpening] = useState(false);
// The card under the button, introducing Framey with the app's download.
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const trigger = useRef<HTMLButtonElement>(null);
const eye = useRef<SVGGElement>(null);
useGaze(trigger, eye);
if (!available) return null;
const open = async () => {
if (!HANDOFF_READY) {
trackStudioEvent("toolbar_action", { action: "open_in_desktop" });
setDownloadUrl(DOWNLOAD_URL);
return;
}
if (opening) return;
setOpening(true);
trackStudioEvent("toolbar_action", { action: "open_in_desktop" });
try {
const res = await fetch(ROUTE, { method: "POST" });
const result = (await res.json()) as OpenResult;
if (result.opened) {
showToast("Framey is opening this project in HyperFrames Studio", "info");
await new Promise((done) => setTimeout(done, OPENING_MS));
} else setDownloadUrl(result.downloadUrl);
} catch {
showToast("Couldn't reach the preview server to open the app", "error");
} finally {
setOpening(false);
}
};
return (
<Popover
open={downloadUrl !== null}
// The popover owns the trigger's press: a press runs the hand-off, and the card shows only when there is
// something to say (the download). A second handler on the button raced this one.
onOpenChange={(next) => {
if (next) void open();
else setDownloadUrl(null);
}}
side="bottom"
align="end"
arrow
aria-label="Meet Framey"
className="w-72"
trigger={
<Button
ref={trigger}
variant="ghost"
data-testid="header-open-in-desktop"
data-opening={opening || undefined}
title="Open this project in HyperFrames Studio, the desktop app, and edit it with Framey"
className="hf-framey-trigger gap-1.5 pl-1.5"
icon={<FrameyGlyph size={20} className="hf-framey" eyeRef={eye} />}
>
{opening ? "Opening in the app…" : "Edit with Framey"}
</Button>
}
>
<div className="flex items-center gap-2.5">
<FrameyGlyph size={36} />
<p className="text-step-12 font-medium text-text-0">Meet Framey</p>
</div>
<p className="mt-2 text-text-3">
I live in HyperFrames Studio, the free desktop app for macOS:
</p>
<ul className="mt-1.5 grid gap-1 text-text-1">
<li>Chat to change anything in the video</li>
<li>Draw on a frame to point at it</li>
<li>Watch me act out each edit</li>
</ul>
<div className="mt-3 flex items-center justify-end gap-2 whitespace-nowrap">
<Button variant="ghost" onClick={() => setDownloadUrl(null)}>
Not now
</Button>
<a
href={downloadUrl ?? undefined}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonBase, buttonVariants.primary, buttonSizes.md)}
onClick={() => trackStudioEvent("toolbar_action", { action: "get_desktop_app" })}
>
Get HyperFrames Studio
</a>
</div>
</Popover>
);
}
@@ -40,6 +40,8 @@ vi.mock("../contexts/PanelLayoutContext", () => ({
}));
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
// Probes the preview server on mount; covered in OpenInDesktopButton.dom.test.tsx.
vi.mock("./OpenInDesktopButton", () => ({ OpenInDesktopButton: () => null }));
const { StudioHeader } = await import("./StudioHeader");
const { ShowThemeToggle } = await import("./ThemeToggle");
@@ -6,6 +6,7 @@ import { trackStudioEvent } from "../utils/studioTelemetry";
import { Button, buttonBase, buttonSizes, buttonVariants, cn, Tooltip } from "./ui";
import { Dock } from "./dock/Dock";
import { InspectorIcon } from "./icons/InspectorIcon";
import { OpenInDesktopButton } from "./OpenInDesktopButton";
import { HyperframesLogo } from "./ui/HyperframesLogo";
import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle";
@@ -151,6 +152,7 @@ export const StudioHeader = memo(function StudioHeader({
</div>
{showThemeToggle && <ThemeToggle />}
<Dock.WindowMenu />
<OpenInDesktopButton />
<Tooltip
label={
ffmpegMissing
+51
View File
@@ -935,3 +935,54 @@
transform: scaleY(1);
}
}
/* Framey on the header's "Edit with Framey" (OpenInDesktopButton.tsx): a wiggle on hover, a flight towards
* the app while it opens. The eye follows the pointer from script. */
@keyframes hf-framey-wiggle {
0%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-14deg);
}
60% {
transform: rotate(10deg);
}
}
@keyframes hf-framey-fly {
70% {
opacity: 1;
}
100% {
transform: translate(48px, -28px) rotate(25deg) scale(0.6);
opacity: 0;
}
}
.hf-framey {
transform-origin: 30% 30%;
}
.hf-framey-eye {
transition: transform 80ms linear;
}
.hf-framey-trigger:hover .hf-framey {
animation: hf-framey-wiggle 0.5s ease-in-out;
}
.hf-framey-trigger[data-opening] .hf-framey {
animation: hf-framey-fly 0.9s ease-in forwards;
}
@media (prefers-reduced-motion: reduce) {
.hf-framey-trigger:hover .hf-framey,
.hf-framey-trigger[data-opening] .hf-framey {
animation: none;
}
.hf-framey-eye {
transition: none;
}
}
+3
View File
@@ -436,6 +436,9 @@
--color-violet-400: oklch(70.2% 0.183 293.541);
--color-violet-500: oklch(60.6% 0.25 292.717);
--color-white: #fff;
/* Framey, the desktop app's cursor mascot (hyperframes-internal packages/nib): the same in both themes. */
--color-framey: #3ce6ac;
--color-framey-ink: #16121a;
}
/* The colour-scheme picks each token's light or dark half: dark unless the document element