Skip to content

Commit 2d84937

Browse files
feat(studio): export the header logo, button sizes and a stable styles.css subpath (heygen-com#4197)
A host mounting Studio's header reuses HyperframesLogo and buttonSizes for matching chrome, and imports the compiled stylesheet as a fixed-name package export instead of a hash that changes on every rebuild.
1 parent 98e8372 commit 2d84937

7 files changed

Lines changed: 161 additions & 3 deletions

File tree

‎packages/studio/package-subpaths.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919
"types": null,
2020
"environments": ["browser"]
2121
},
22+
"./styles.css": {
23+
"source": "./src/styles/studio.css",
24+
"runtime": "./dist/styles.css",
25+
"types": null,
26+
"environments": ["browser"]
27+
},
2228
"./package.json": {
2329
"source": "./package.json",
2430
"runtime": "./package.json",

‎packages/studio/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"types": "./src/styles/tailwind-preset.ts"
2828
},
2929
"./theme.css": "./src/styles/theme.css",
30+
"./styles.css": "./src/styles/studio.css",
3031
"./package.json": "./package.json"
3132
},
3233
"publishConfig": {
@@ -41,6 +42,7 @@
4142
"types": "./dist/styles/tailwind-preset.d.ts"
4243
},
4344
"./theme.css": "./src/styles/theme.css",
45+
"./styles.css": "./dist/styles.css",
4446
"./package.json": "./package.json"
4547
},
4648
"main": "./dist/index.js",

‎packages/studio/src/components/StudioHeader.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export interface StudioHeaderProps {
1717
onExport?: () => void;
1818
}
1919

20-
function HyperframesLogo() {
20+
export function HyperframesLogo() {
2121
// Full logo from logo-dark.svg (263×79): heygen label + gradient mark + hyperframes wordmark.
2222
// All fill="black" paths inverted to white for the dark header.
2323
const height = 28;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// @vitest-environment happy-dom
2+
// Imports the header logo and button size classes the way a host app does: by package name.
3+
import { createRoot } from "react-dom/client";
4+
import { act } from "react";
5+
import { describe, expect, it } from "vitest";
6+
import { HyperframesLogo, buttonSizes } from "@hyperframes/studio";
7+
8+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
9+
10+
describe("header package exports", () => {
11+
it("mounts the logo mark", async () => {
12+
const el = document.createElement("div");
13+
document.body.append(el);
14+
const root = createRoot(el);
15+
await act(async () => root.render(<HyperframesLogo />));
16+
expect(el.querySelector("svg")).not.toBeNull();
17+
await act(async () => root.unmount());
18+
});
19+
20+
it("exposes the button size classes a host reuses for matching chrome", () => {
21+
expect(buttonSizes.md).toEqual(expect.any(String));
22+
});
23+
});

‎packages/studio/src/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
// UI primitives
2-
export { Button, buttonBase, buttonVariants } from "./components/ui/Button";
2+
export { Button, buttonBase, buttonSizes, buttonVariants } from "./components/ui/Button";
33
export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button";
44
export { IconButton } from "./components/ui/IconButton";
55
export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
66
export { Tooltip } from "./components/ui/Tooltip";
7+
export { HyperframesLogo } from "./components/StudioHeader";
78
export { cn } from "./components/ui/cn";
89
export {
910
ContextMenu,
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join, resolve } from "node:path";
4+
import { describe, expect, it } from "vitest";
5+
import config, { stableStylesCssPlugin } from "./vite.config";
6+
7+
const packageJson = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf8")) as {
8+
publishConfig: { exports: Record<string, string> };
9+
};
10+
const subpaths = JSON.parse(readFileSync(resolve(__dirname, "package-subpaths.json"), "utf8")) as {
11+
subpaths: Record<string, { runtime: string }>;
12+
};
13+
14+
// Rollup's own asset/chunk types aren't imported here; the plugin only reads
15+
// `type` and `fileName`, so a minimal shape is enough to drive it.
16+
function asset(fileName: string): { type: "asset"; fileName: string } {
17+
return { type: "asset", fileName };
18+
}
19+
function chunk(fileName: string): { type: "chunk"; fileName: string } {
20+
return { type: "chunk", fileName };
21+
}
22+
23+
function callWriteBundle(dir: string, bundle: Record<string, unknown>): void {
24+
const writeBundle = stableStylesCssPlugin().writeBundle as (
25+
options: { dir: string },
26+
bundle: Record<string, unknown>,
27+
) => void;
28+
writeBundle({ dir }, bundle);
29+
}
30+
31+
describe("build.rollupOptions", () => {
32+
it("does not override assetFileNames, so every asset stays content-hashed", () => {
33+
// Regression for the cache bug this PR fixed: /assets/* is served with a
34+
// one-year immutable Cache-Control by filename convention alone
35+
// (packages/cli/src/server/studioServer.ts), so an unhashed name there
36+
// sticks to every CLI user's cache for a year.
37+
expect(config.build?.rollupOptions?.output).toBeUndefined();
38+
});
39+
});
40+
41+
describe("./styles.css export path", () => {
42+
it("resolves outside dist/assets, so it never inherits the immutable cache header", () => {
43+
const publishedPath = packageJson.publishConfig.exports["./styles.css"];
44+
const runtimePath = subpaths.subpaths["./styles.css"]?.runtime;
45+
expect(publishedPath).toBe("./dist/styles.css");
46+
expect(runtimePath).toBe("./dist/styles.css");
47+
for (const path of [publishedPath, runtimePath]) {
48+
expect(path?.startsWith("./dist/assets/")).toBe(false);
49+
}
50+
});
51+
});
52+
53+
describe("stableStylesCssPlugin", () => {
54+
function tmpDir(): string {
55+
return mkdtempSync(join(tmpdir(), "styles-css-plugin-"));
56+
}
57+
58+
it("copies the single CSS asset, unhashed, to dist root, ignoring non-asset bundle entries", () => {
59+
const dir = tmpDir();
60+
try {
61+
writeFileSync(join(dir, "app-abc123.css"), "body{color:red}");
62+
callWriteBundle(dir, {
63+
"app-abc123.css": asset("app-abc123.css"),
64+
"app-def456.js": chunk("app-def456.js"),
65+
// A chunk can't really carry a `.css` name, but proving the `type`
66+
// guard (not just the filename suffix) does the filtering keeps the
67+
// count right if a future asset kind ever ends in `.css` too.
68+
"fake.css": chunk("fake.css"),
69+
});
70+
expect(readFileSync(join(dir, "styles.css"), "utf8")).toBe("body{color:red}");
71+
} finally {
72+
rmSync(dir, { recursive: true, force: true });
73+
}
74+
});
75+
76+
it("throws instead of guessing when the build emits zero or several CSS assets", () => {
77+
const dir = tmpDir();
78+
try {
79+
expect(() => callWriteBundle(dir, { "app.js": chunk("app.js") })).toThrow(/found 0/);
80+
writeFileSync(join(dir, "a.css"), "a");
81+
writeFileSync(join(dir, "b.css"), "b");
82+
expect(() =>
83+
callWriteBundle(dir, { "a.css": asset("a.css"), "b.css": asset("b.css") }),
84+
).toThrow(/found 2/);
85+
} finally {
86+
rmSync(dir, { recursive: true, force: true });
87+
}
88+
});
89+
});

‎packages/studio/vite.config.ts‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { defineConfig, type Plugin } from "vite";
22
import react from "@vitejs/plugin-react";
33
import tailwindcss from "@tailwindcss/vite";
4-
import { readFileSync, readdirSync, existsSync, lstatSync, realpathSync } from "node:fs";
4+
import {
5+
copyFileSync,
6+
readFileSync,
7+
readdirSync,
8+
existsSync,
9+
lstatSync,
10+
realpathSync,
11+
} from "node:fs";
512
import { join, resolve } from "node:path";
613
import { readNodeRequestBody } from "./vite.request-body.js";
714
import { watch } from "chokidar";
@@ -27,6 +34,30 @@ async function loadRuntimeSourceForDev(
2734

2835
const studioPkg = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf-8"));
2936

37+
/**
38+
* Copies the build's one CSS asset, unhashed, to `dist/styles.css` for the
39+
* `./styles.css` export. Throws if the build ever emits more than one.
40+
*/
41+
export function stableStylesCssPlugin(): Plugin {
42+
return {
43+
name: "studio-stable-styles-css",
44+
writeBundle(options, bundle) {
45+
const cssAssets = Object.values(bundle).filter(
46+
(item) => item.type === "asset" && item.fileName.endsWith(".css"),
47+
);
48+
if (cssAssets.length !== 1) {
49+
throw new Error(
50+
`stableStylesCssPlugin: expected exactly one CSS asset for the ./styles.css ` +
51+
`export, found ${cssAssets.length} (${cssAssets.map((a) => a.fileName).join(", ") || "none"}). ` +
52+
`Scope this plugin to the entry stylesheet instead of assuming a single emit.`,
53+
);
54+
}
55+
const outDir = options.dir ?? "dist";
56+
copyFileSync(join(outDir, cssAssets[0]!.fileName), join(outDir, "styles.css"));
57+
},
58+
};
59+
}
60+
3061
// ── Bridge Hono fetch → Node http response ───────────────────────────────────
3162

3263
async function bridgeHonoResponse(
@@ -262,6 +293,12 @@ export default defineConfig({
262293
build: {
263294
outDir: "dist",
264295
emptyOutDir: true,
296+
rollupOptions: {
297+
// /assets/* caches by filename alone, immutably, for a year
298+
// (studioServer.ts). Keep every hash; copy one CSS file, unhashed,
299+
// to the dist ROOT instead for the ./styles.css export.
300+
plugins: [stableStylesCssPlugin()],
301+
},
265302
},
266303
optimizeDeps: {
267304
include: ["bpm-detective"],

0 commit comments

Comments
 (0)