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.
This commit is contained in:
Miguel Ángel
2026-09-19 19:06:01 -04:00
committed by GitHub
parent 98e8372a0a
commit 2d84937e1f
7 changed files with 161 additions and 3 deletions
+6
View File
@@ -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",
+2
View File
@@ -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",
@@ -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;
@@ -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(<HyperframesLogo />));
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));
});
});
+2 -1
View File
@@ -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,
+89
View File
@@ -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<string, string> };
};
const subpaths = JSON.parse(readFileSync(resolve(__dirname, "package-subpaths.json"), "utf8")) as {
subpaths: Record<string, { runtime: string }>;
};
// 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<string, unknown>): void {
const writeBundle = stableStylesCssPlugin().writeBundle as (
options: { dir: string },
bundle: Record<string, unknown>,
) => 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 });
}
});
});
+38 -1
View File
@@ -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"],