From 2d84937e1f28ee60dd7dc23bdedfd324793e10d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 19 Sep 2026 19:06:01 -0400 Subject: [PATCH] feat(studio): export the header logo, button sizes and a stable styles.css subpath (#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. --- packages/studio/package-subpaths.json | 6 ++ packages/studio/package.json | 2 + .../studio/src/components/StudioHeader.tsx | 2 +- packages/studio/src/headerExports.test.tsx | 23 +++++ packages/studio/src/index.ts | 3 +- packages/studio/vite.config.test.ts | 89 +++++++++++++++++++ packages/studio/vite.config.ts | 39 +++++++- 7 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 packages/studio/src/headerExports.test.tsx create mode 100644 packages/studio/vite.config.test.ts 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"],