mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -152,6 +152,7 @@ const commandLoaders = {
|
|||||||
preview: () =>
|
preview: () =>
|
||||||
assertStudioWorkspaceBuilt().then(() => import("./commands/preview.js").then((m) => m.default)),
|
assertStudioWorkspaceBuilt().then(() => import("./commands/preview.js").then((m) => m.default)),
|
||||||
publish: () => import("./commands/publish.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),
|
render: () => import("./commands/render.js").then((m) => m.default),
|
||||||
lint: () => import("./commands/lint.js").then((m) => m.default),
|
lint: () => import("./commands/lint.js").then((m) => m.default),
|
||||||
check: () => import("./commands/check.js").then((m) => m.default),
|
check: () => import("./commands/check.js").then((m) => m.default),
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -39,6 +39,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import * as clack from "@clack/prompts";
|
import * as clack from "@clack/prompts";
|
||||||
import { c } from "../ui/colors.js";
|
import { c } from "../ui/colors.js";
|
||||||
|
import { desktopHint } from "../utils/desktopApp.js";
|
||||||
import { isDevMode } from "../utils/env.js";
|
import { isDevMode } from "../utils/env.js";
|
||||||
import { normalizeErrorMessage as errorMessage } from "../utils/errorMessage.js";
|
import { normalizeErrorMessage as errorMessage } from "../utils/errorMessage.js";
|
||||||
import { buildNpxCommand } from "../utils/npxCommand.js";
|
import { buildNpxCommand } from "../utils/npxCommand.js";
|
||||||
@@ -365,7 +366,7 @@ export default defineCommand({
|
|||||||
);
|
);
|
||||||
return;
|
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}`],
|
details: [`Background preview running (PID ${status.pid}).`, `Log: ${status.logPath}`],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -539,7 +540,7 @@ export default defineCommand({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
clack.intro(c.bold("hyperframes preview"));
|
clack.intro(c.bold("hyperframes preview"));
|
||||||
printStudioSummary(projectName, url, {
|
printStudioSummary(projectName, url, dir, {
|
||||||
details: [
|
details: [
|
||||||
background.type === "reused"
|
background.type === "reused"
|
||||||
? "Reusing the background server already running for this project."
|
? "Reusing the background server already running for this project."
|
||||||
@@ -1282,6 +1283,7 @@ function openStudioBrowser(url: string, projectName: string, options?: BrowserLa
|
|||||||
function printStudioSummary(
|
function printStudioSummary(
|
||||||
projectName: string,
|
projectName: string,
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
|
projectDir: string,
|
||||||
opts: { details?: string[]; footer?: string } = {},
|
opts: { details?: string[]; footer?: string } = {},
|
||||||
): void {
|
): void {
|
||||||
const urls = studioSummaryUrls(projectName, serverUrl);
|
const urls = studioSummaryUrls(projectName, serverUrl);
|
||||||
@@ -1289,6 +1291,8 @@ function printStudioSummary(
|
|||||||
console.log(` ${c.dim("Project")} ${c.accent(projectName)}`);
|
console.log(` ${c.dim("Project")} ${c.accent(projectName)}`);
|
||||||
console.log(` ${c.dim("Studio")} ${c.accent(urls.studioUrl)}`);
|
console.log(` ${c.dim("Studio")} ${c.accent(urls.studioUrl)}`);
|
||||||
console.log(` ${c.dim("Server")} ${c.accent(urls.serverUrl)}`);
|
console.log(` ${c.dim("Server")} ${c.accent(urls.serverUrl)}`);
|
||||||
|
const hint = desktopHint(projectDir);
|
||||||
|
if (hint) console.log(` ${c.dim("Desktop")} ${hint}`);
|
||||||
console.log();
|
console.log();
|
||||||
for (const detail of opts.details ?? []) {
|
for (const detail of opts.details ?? []) {
|
||||||
console.log(` ${c.dim(detail)}`);
|
console.log(` ${c.dim(detail)}`);
|
||||||
@@ -1395,7 +1399,7 @@ function attachStudioReadyHandler(
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
spinner.stop(c.success("Studio running"));
|
spinner.stop(c.success("Studio running"));
|
||||||
printStudioSummary(projectName, url, {
|
printStudioSummary(projectName, url, projectDir, {
|
||||||
footer: "Press Ctrl+C to stop",
|
footer: "Press Ctrl+C to stop",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1629,7 +1633,7 @@ async function runEmbeddedMode(
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
s.stop(c.success("Already running"));
|
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."],
|
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(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`);
|
||||||
console.log();
|
console.log();
|
||||||
}
|
}
|
||||||
printStudioSummary(pName, url, {
|
printStudioSummary(pName, url, dir, {
|
||||||
details: [
|
details: [
|
||||||
"Edit with your AI agent — it has HyperFrames skills installed.",
|
"Edit with your AI agent — it has HyperFrames skills installed.",
|
||||||
"Changes reload automatically in the studio.",
|
"Changes reload automatically in the studio.",
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import { resolve, dirname, join, basename } from "node:path";
|
|||||||
import { execFileSync, spawn } from "node:child_process";
|
import { execFileSync, spawn } from "node:child_process";
|
||||||
import { loadProducer } from "../utils/producer.js";
|
import { loadProducer } from "../utils/producer.js";
|
||||||
import { c } from "../ui/colors.js";
|
import { c } from "../ui/colors.js";
|
||||||
|
import { desktopHint } from "../utils/desktopApp.js";
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatRenderSummaryDetail,
|
formatRenderSummaryDetail,
|
||||||
@@ -524,6 +525,8 @@ export interface RenderOptions {
|
|||||||
throwOnError?: boolean;
|
throwOnError?: boolean;
|
||||||
/** Skip the interactive feedback prompt after a successful render. */
|
/** Skip the interactive feedback prompt after a successful render. */
|
||||||
skipFeedback?: boolean;
|
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
|
* OPT IN to managing the DE parallel-router circuit breaker
|
||||||
* (`applyDeParallelRouterCircuitBreaker`) for this render. Default OFF —
|
* (`applyDeParallelRouterCircuitBreaker`) for this render. Default OFF —
|
||||||
@@ -886,6 +889,8 @@ async function renderDocker(
|
|||||||
runPostRenderStep("printRenderComplete", () =>
|
runPostRenderStep("printRenderComplete", () =>
|
||||||
printRenderComplete({
|
printRenderComplete({
|
||||||
outputPath,
|
outputPath,
|
||||||
|
projectDir,
|
||||||
|
desktopHint: options.desktopHint !== false && options.quality !== "draft",
|
||||||
elapsedMs: elapsed,
|
elapsedMs: elapsed,
|
||||||
quiet: options.quiet,
|
quiet: options.quiet,
|
||||||
format: options.format,
|
format: options.format,
|
||||||
@@ -1138,6 +1143,8 @@ async function executeLocalRender(
|
|||||||
runPostRenderStep("printRenderComplete", () =>
|
runPostRenderStep("printRenderComplete", () =>
|
||||||
printRenderComplete({
|
printRenderComplete({
|
||||||
outputPath,
|
outputPath,
|
||||||
|
projectDir,
|
||||||
|
desktopHint: options.desktopHint !== false && options.quality !== "draft",
|
||||||
elapsedMs: elapsed,
|
elapsedMs: elapsed,
|
||||||
quiet: options.quiet,
|
quiet: options.quiet,
|
||||||
format: options.format,
|
format: options.format,
|
||||||
@@ -1900,6 +1907,9 @@ function readOutputFootprint(outputPath: string): { fileSize: string; isDirector
|
|||||||
|
|
||||||
function printRenderComplete(input: {
|
function printRenderComplete(input: {
|
||||||
outputPath: string;
|
outputPath: string;
|
||||||
|
projectDir: string;
|
||||||
|
/** Print the desktop-app line: a delivered render, never a draft or a batch row. */
|
||||||
|
desktopHint: boolean;
|
||||||
elapsedMs: number;
|
elapsedMs: number;
|
||||||
quiet: boolean;
|
quiet: boolean;
|
||||||
format: RenderFormat;
|
format: RenderFormat;
|
||||||
@@ -1920,6 +1930,8 @@ function printRenderComplete(input: {
|
|||||||
console.log(c.success("\u25C7") + " " + c.accent(outputPath));
|
console.log(c.success("\u25C7") + " " + c.accent(outputPath));
|
||||||
console.log(" " + c.bold(fileSize) + c.dim(" \u00B7 " + detail));
|
console.log(" " + c.bold(fileSize) + c.dim(" \u00B7 " + detail));
|
||||||
if (perf) printRenderPipeline(perf, input.requestedGpuMode);
|
if (perf) printRenderPipeline(perf, input.requestedGpuMode);
|
||||||
|
const hint = input.desktopHint ? desktopHint(input.projectDir) : null;
|
||||||
|
if (hint) console.log(" " + c.dim(hint));
|
||||||
}
|
}
|
||||||
|
|
||||||
function printRenderPipeline(
|
function printRenderPipeline(
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ async function executeBatchRender(
|
|||||||
exitAfterComplete: false,
|
exitAfterComplete: false,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
skipFeedback: true,
|
skipFeedback: true,
|
||||||
|
desktopHint: false,
|
||||||
manageDeParallelRouterBreaker: plan.batchConcurrency <= 1,
|
manageDeParallelRouterBreaker: plan.batchConcurrency <= 1,
|
||||||
};
|
};
|
||||||
const manifest = await batchModule.runBatchRender({
|
const manifest = await batchModule.runBatchRender({
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const GROUPS: Group[] = [
|
|||||||
["present", "Open a slideshow deck in presenter mode (with audience sync)"],
|
["present", "Open a slideshow deck in presenter mode (with audience sync)"],
|
||||||
["publish", "Upload a project to a stable URL (private by default)"],
|
["publish", "Upload a project to a stable URL (private by default)"],
|
||||||
["render", "Render a composition to MP4 or WebM"],
|
["render", "Render a composition to MP4 or WebM"],
|
||||||
|
["open", "Open a project in HyperFrames Studio, the desktop app"],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────
|
// ── Pre-flight checks for render ────────────────────────────────────────
|
||||||
// Intercept render requests before they reach the shared API so we can
|
// Intercept render requests before they reach the shared API so we can
|
||||||
// fail fast with an actionable hint instead of burning through the entire
|
// fail fast with an actionable hint instead of burning through the entire
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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() }));
|
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 { StudioHeader } = await import("./StudioHeader");
|
||||||
const { ShowThemeToggle } = await import("./ThemeToggle");
|
const { ShowThemeToggle } = await import("./ThemeToggle");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { trackStudioEvent } from "../utils/studioTelemetry";
|
|||||||
import { Button, buttonBase, buttonSizes, buttonVariants, cn, Tooltip } from "./ui";
|
import { Button, buttonBase, buttonSizes, buttonVariants, cn, Tooltip } from "./ui";
|
||||||
import { Dock } from "./dock/Dock";
|
import { Dock } from "./dock/Dock";
|
||||||
import { InspectorIcon } from "./icons/InspectorIcon";
|
import { InspectorIcon } from "./icons/InspectorIcon";
|
||||||
|
import { OpenInDesktopButton } from "./OpenInDesktopButton";
|
||||||
import { HyperframesLogo } from "./ui/HyperframesLogo";
|
import { HyperframesLogo } from "./ui/HyperframesLogo";
|
||||||
import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle";
|
import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle";
|
||||||
|
|
||||||
@@ -151,6 +152,7 @@ export const StudioHeader = memo(function StudioHeader({
|
|||||||
</div>
|
</div>
|
||||||
{showThemeToggle && <ThemeToggle />}
|
{showThemeToggle && <ThemeToggle />}
|
||||||
<Dock.WindowMenu />
|
<Dock.WindowMenu />
|
||||||
|
<OpenInDesktopButton />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={
|
label={
|
||||||
ffmpegMissing
|
ffmpegMissing
|
||||||
|
|||||||
@@ -935,3 +935,54 @@
|
|||||||
transform: scaleY(1);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -436,6 +436,9 @@
|
|||||||
--color-violet-400: oklch(70.2% 0.183 293.541);
|
--color-violet-400: oklch(70.2% 0.183 293.541);
|
||||||
--color-violet-500: oklch(60.6% 0.25 292.717);
|
--color-violet-500: oklch(60.6% 0.25 292.717);
|
||||||
--color-white: #fff;
|
--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
|
/* The colour-scheme picks each token's light or dark half: dark unless the document element
|
||||||
|
|||||||
Reference in New Issue
Block a user