diff --git a/packages/studio/package-subpaths.json b/packages/studio/package-subpaths.json
index 4bbea0a261..ac6ec74faf 100644
--- a/packages/studio/package-subpaths.json
+++ b/packages/studio/package-subpaths.json
@@ -19,6 +19,12 @@
"types": null,
"environments": ["browser"]
},
+ "./styles.css": {
+ "source": "./src/styles/studio.css",
+ "runtime": "./dist/styles.css",
+ "types": null,
+ "environments": ["browser"]
+ },
"./package.json": {
"source": "./package.json",
"runtime": "./package.json",
diff --git a/packages/studio/package.json b/packages/studio/package.json
index 744ee96b6a..79a4d578e2 100644
--- a/packages/studio/package.json
+++ b/packages/studio/package.json
@@ -27,6 +27,7 @@
"types": "./src/styles/tailwind-preset.ts"
},
"./theme.css": "./src/styles/theme.css",
+ "./styles.css": "./src/styles/studio.css",
"./package.json": "./package.json"
},
"publishConfig": {
@@ -41,6 +42,7 @@
"types": "./dist/styles/tailwind-preset.d.ts"
},
"./theme.css": "./src/styles/theme.css",
+ "./styles.css": "./dist/styles.css",
"./package.json": "./package.json"
},
"main": "./dist/index.js",
diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx
index 7b88371373..10b733c830 100644
--- a/packages/studio/src/components/StudioHeader.tsx
+++ b/packages/studio/src/components/StudioHeader.tsx
@@ -17,7 +17,7 @@ export interface StudioHeaderProps {
onExport?: () => void;
}
-function HyperframesLogo() {
+export function HyperframesLogo() {
// Full logo from logo-dark.svg (263×79): heygen label + gradient mark + hyperframes wordmark.
// All fill="black" paths inverted to white for the dark header.
const height = 28;
diff --git a/packages/studio/src/headerExports.test.tsx b/packages/studio/src/headerExports.test.tsx
new file mode 100644
index 0000000000..348fafef44
--- /dev/null
+++ b/packages/studio/src/headerExports.test.tsx
@@ -0,0 +1,23 @@
+// @vitest-environment happy-dom
+// Imports the header logo and button size classes the way a host app does: by package name.
+import { createRoot } from "react-dom/client";
+import { act } from "react";
+import { describe, expect, it } from "vitest";
+import { HyperframesLogo, buttonSizes } from "@hyperframes/studio";
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+describe("header package exports", () => {
+ it("mounts the logo mark", async () => {
+ const el = document.createElement("div");
+ document.body.append(el);
+ const root = createRoot(el);
+ await act(async () => root.render());
+ expect(el.querySelector("svg")).not.toBeNull();
+ await act(async () => root.unmount());
+ });
+
+ it("exposes the button size classes a host reuses for matching chrome", () => {
+ expect(buttonSizes.md).toEqual(expect.any(String));
+ });
+});
diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts
index 9608bc6ca8..9eb5473131 100644
--- a/packages/studio/src/index.ts
+++ b/packages/studio/src/index.ts
@@ -1,9 +1,10 @@
// UI primitives
-export { Button, buttonBase, buttonVariants } from "./components/ui/Button";
+export { Button, buttonBase, buttonSizes, buttonVariants } from "./components/ui/Button";
export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button";
export { IconButton } from "./components/ui/IconButton";
export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
export { Tooltip } from "./components/ui/Tooltip";
+export { HyperframesLogo } from "./components/StudioHeader";
export { cn } from "./components/ui/cn";
export {
ContextMenu,
diff --git a/packages/studio/vite.config.test.ts b/packages/studio/vite.config.test.ts
new file mode 100644
index 0000000000..29f35e916e
--- /dev/null
+++ b/packages/studio/vite.config.test.ts
@@ -0,0 +1,89 @@
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+import config, { stableStylesCssPlugin } from "./vite.config";
+
+const packageJson = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf8")) as {
+ publishConfig: { exports: Record };
+};
+const subpaths = JSON.parse(readFileSync(resolve(__dirname, "package-subpaths.json"), "utf8")) as {
+ subpaths: Record;
+};
+
+// Rollup's own asset/chunk types aren't imported here; the plugin only reads
+// `type` and `fileName`, so a minimal shape is enough to drive it.
+function asset(fileName: string): { type: "asset"; fileName: string } {
+ return { type: "asset", fileName };
+}
+function chunk(fileName: string): { type: "chunk"; fileName: string } {
+ return { type: "chunk", fileName };
+}
+
+function callWriteBundle(dir: string, bundle: Record): void {
+ const writeBundle = stableStylesCssPlugin().writeBundle as (
+ options: { dir: string },
+ bundle: Record,
+ ) => void;
+ writeBundle({ dir }, bundle);
+}
+
+describe("build.rollupOptions", () => {
+ it("does not override assetFileNames, so every asset stays content-hashed", () => {
+ // Regression for the cache bug this PR fixed: /assets/* is served with a
+ // one-year immutable Cache-Control by filename convention alone
+ // (packages/cli/src/server/studioServer.ts), so an unhashed name there
+ // sticks to every CLI user's cache for a year.
+ expect(config.build?.rollupOptions?.output).toBeUndefined();
+ });
+});
+
+describe("./styles.css export path", () => {
+ it("resolves outside dist/assets, so it never inherits the immutable cache header", () => {
+ const publishedPath = packageJson.publishConfig.exports["./styles.css"];
+ const runtimePath = subpaths.subpaths["./styles.css"]?.runtime;
+ expect(publishedPath).toBe("./dist/styles.css");
+ expect(runtimePath).toBe("./dist/styles.css");
+ for (const path of [publishedPath, runtimePath]) {
+ expect(path?.startsWith("./dist/assets/")).toBe(false);
+ }
+ });
+});
+
+describe("stableStylesCssPlugin", () => {
+ function tmpDir(): string {
+ return mkdtempSync(join(tmpdir(), "styles-css-plugin-"));
+ }
+
+ it("copies the single CSS asset, unhashed, to dist root, ignoring non-asset bundle entries", () => {
+ const dir = tmpDir();
+ try {
+ writeFileSync(join(dir, "app-abc123.css"), "body{color:red}");
+ callWriteBundle(dir, {
+ "app-abc123.css": asset("app-abc123.css"),
+ "app-def456.js": chunk("app-def456.js"),
+ // A chunk can't really carry a `.css` name, but proving the `type`
+ // guard (not just the filename suffix) does the filtering keeps the
+ // count right if a future asset kind ever ends in `.css` too.
+ "fake.css": chunk("fake.css"),
+ });
+ expect(readFileSync(join(dir, "styles.css"), "utf8")).toBe("body{color:red}");
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("throws instead of guessing when the build emits zero or several CSS assets", () => {
+ const dir = tmpDir();
+ try {
+ expect(() => callWriteBundle(dir, { "app.js": chunk("app.js") })).toThrow(/found 0/);
+ writeFileSync(join(dir, "a.css"), "a");
+ writeFileSync(join(dir, "b.css"), "b");
+ expect(() =>
+ callWriteBundle(dir, { "a.css": asset("a.css"), "b.css": asset("b.css") }),
+ ).toThrow(/found 2/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts
index 2c4dd85e18..af78da1b4c 100644
--- a/packages/studio/vite.config.ts
+++ b/packages/studio/vite.config.ts
@@ -1,7 +1,14 @@
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
-import { readFileSync, readdirSync, existsSync, lstatSync, realpathSync } from "node:fs";
+import {
+ copyFileSync,
+ readFileSync,
+ readdirSync,
+ existsSync,
+ lstatSync,
+ realpathSync,
+} from "node:fs";
import { join, resolve } from "node:path";
import { readNodeRequestBody } from "./vite.request-body.js";
import { watch } from "chokidar";
@@ -27,6 +34,30 @@ async function loadRuntimeSourceForDev(
const studioPkg = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf-8"));
+/**
+ * Copies the build's one CSS asset, unhashed, to `dist/styles.css` for the
+ * `./styles.css` export. Throws if the build ever emits more than one.
+ */
+export function stableStylesCssPlugin(): Plugin {
+ return {
+ name: "studio-stable-styles-css",
+ writeBundle(options, bundle) {
+ const cssAssets = Object.values(bundle).filter(
+ (item) => item.type === "asset" && item.fileName.endsWith(".css"),
+ );
+ if (cssAssets.length !== 1) {
+ throw new Error(
+ `stableStylesCssPlugin: expected exactly one CSS asset for the ./styles.css ` +
+ `export, found ${cssAssets.length} (${cssAssets.map((a) => a.fileName).join(", ") || "none"}). ` +
+ `Scope this plugin to the entry stylesheet instead of assuming a single emit.`,
+ );
+ }
+ const outDir = options.dir ?? "dist";
+ copyFileSync(join(outDir, cssAssets[0]!.fileName), join(outDir, "styles.css"));
+ },
+ };
+}
+
// ── Bridge Hono fetch → Node http response ───────────────────────────────────
async function bridgeHonoResponse(
@@ -262,6 +293,12 @@ export default defineConfig({
build: {
outDir: "dist",
emptyOutDir: true,
+ rollupOptions: {
+ // /assets/* caches by filename alone, immutably, for a year
+ // (studioServer.ts). Keep every hash; copy one CSS file, unhashed,
+ // to the dist ROOT instead for the ./styles.css export.
+ plugins: [stableStylesCssPlugin()],
+ },
},
optimizeDeps: {
include: ["bpm-detective"],