diff --git a/README.md b/README.md
index d1c5026..f39dcb5 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ This MVP uses Drizzle with Bun SQLite for local file-backed persistence.
## Scripts
-- `bun run dev`: start the local development server.
+- `bun run dev`: start the local Vite development server with a Bun API proxy.
- `bun run check`: run TypeScript checks.
- `bun run test`: run Vitest.
- `bun run test:unit`: run Vitest explicitly as the unit test stage.
@@ -63,10 +63,11 @@ can replace them without changing presentation components.
## Runtime Shape
-The browser app is built with Vite and React. Production uses a small Bun HTTP
-server at `build/index.js` to serve the Vite `dist/` assets and JSON API routes.
-The current persistence runtime is Bun because the MVP SQLite driver is
-`bun:sqlite`.
+The browser app is built with Vite and React. Local development starts Vite for
+HMR and a loopback Bun API server for `/api/*` routes. Production uses a small
+Bun HTTP server at `build/index.js` to serve the Vite `dist/` assets and JSON
+API routes. The current persistence runtime is Bun because the MVP SQLite
+driver is `bun:sqlite`.
## Storybook
diff --git a/index.html b/index.html
index 79a571a..5e47684 100644
--- a/index.html
+++ b/index.html
@@ -3,6 +3,7 @@
+
Dimension Lab
diff --git a/package.json b/package.json
index 91a704a..f1cf4ab 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "vite --host 0.0.0.0",
+ "dev": "bun src/server/dev.ts",
"build": "vite build && bun build src/server/index.ts --target bun --outdir build",
"preview": "HOST=0.0.0.0 PORT=4173 bun build/index.js",
"storybook": "storybook dev -p 6006 --host 0.0.0.0",
diff --git a/src/App.tsx b/src/App.tsx
index 1dc7285..81ec0c3 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -49,10 +49,46 @@ export function AppStateView({
);
}
+export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): {
+ description: string;
+ title: string;
+} {
+ if (dashboard.state === "ready") {
+ const uiDashboard = dashboardDocumentToUiDashboard(dashboard.document);
+ return {
+ title: uiDashboard.title,
+ description:
+ uiDashboard.subtitle ||
+ dashboard.document.metadata.description ||
+ uiDashboard.title,
+ };
+ }
+
+ return {
+ title: dashboard.title,
+ description: dashboard.subtitle || dashboard.message,
+ };
+}
+
export default function App() {
const [dashboard, setDashboard] =
useState(loadingDashboardState);
+ useEffect(() => {
+ const metadata = resolveDocumentMetadata(dashboard);
+ document.title = metadata.title;
+
+ let description = document.querySelector(
+ 'meta[name="description"]',
+ );
+ if (!description) {
+ description = document.createElement("meta");
+ description.name = "description";
+ document.head.append(description);
+ }
+ description.content = metadata.description;
+ }, [dashboard]);
+
useEffect(() => {
let cancelled = false;
let refreshTimer: number | undefined;
diff --git a/src/app.css b/src/app.css
index f851aa0..10a9ee9 100644
--- a/src/app.css
+++ b/src/app.css
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "./lib/ui/tokens.css";
+@import "./lib/ui/components/styles.css";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
diff --git a/src/lib/ui/components/DashboardFrame.tsx b/src/lib/ui/components/DashboardFrame.tsx
index f07e452..e8b8fe2 100644
--- a/src/lib/ui/components/DashboardFrame.tsx
+++ b/src/lib/ui/components/DashboardFrame.tsx
@@ -4,7 +4,6 @@ import { ModuleCard } from "./ModuleCard";
import { ServicePanel } from "./ServicePanel";
import { StatusStrip } from "./StatusStrip";
import { TelemetryGrid } from "./TelemetryGrid";
-import "./styles.css";
export interface DashboardFrameProps {
dashboard: UiDashboardPreview;
diff --git a/src/lib/ui/storybook.test.ts b/src/lib/ui/storybook.test.ts
index 91303a2..f1303c7 100644
--- a/src/lib/ui/storybook.test.ts
+++ b/src/lib/ui/storybook.test.ts
@@ -63,6 +63,17 @@ describe("Storybook inventory", () => {
}
});
+ test("loads dashboard component styles through the global app stylesheet", () => {
+ const appStyles = readFileSync(join(root, "src/app.css"), "utf8");
+ const dashboardFrame = readFileSync(
+ join(componentsDir, "DashboardFrame.tsx"),
+ "utf8",
+ );
+
+ expect(appStyles).toContain('./lib/ui/components/styles.css');
+ expect(dashboardFrame).not.toContain('./styles.css');
+ });
+
test("keeps component and story files paired as the UI inventory changes", () => {
const componentStoryFiles = readdirSync(componentsDir)
.filter((filename) => filename.endsWith(".tsx") && !filename.endsWith(".test.tsx"))
diff --git a/src/page.test.tsx b/src/page.test.tsx
index 45e3d3a..fc68147 100644
--- a/src/page.test.tsx
+++ b/src/page.test.tsx
@@ -1,7 +1,7 @@
import { renderToString } from "react-dom/server";
import { describe, expect, test } from "vitest";
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
-import { AppStateView } from "./App";
+import { AppStateView, resolveDocumentMetadata } from "./App";
describe("home page model renderer", () => {
test("renders the active dashboard model from the runtime state", () => {
@@ -43,6 +43,30 @@ describe("home page model renderer", () => {
expect(body).toContain("/metadata/title is required");
});
+ test("derives browser metadata from ready and fallback runtime states", () => {
+ const ready = resolveDocumentMetadata({
+ state: "ready",
+ document: genericDashboardFixture,
+ schemaVersion: "dashboard.v1",
+ currentRevisionId: "revision-1234567890",
+ });
+ const empty = resolveDocumentMetadata({
+ state: "empty",
+ title: "No Dashboard Model",
+ subtitle: "No active document",
+ message: "No validated dashboard document is active yet.",
+ });
+
+ expect(ready).toEqual({
+ title: "Operations Console",
+ description: "Generic environment",
+ });
+ expect(empty).toEqual({
+ title: "No Dashboard Model",
+ description: "No active document",
+ });
+ });
+
test("renders empty and loading model states without crashing", () => {
const empty = renderToString(
{
+ test("starts the Bun API server together with the Vite dev server", () => {
+ const packageJson = JSON.parse(
+ readFileSync(join(process.cwd(), "package.json"), "utf8"),
+ ) as { scripts?: Record };
+
+ expect(packageJson.scripts?.dev).toBe("bun src/server/dev.ts");
+ });
+
+ test("proxies dashboard API requests from Vite to the Bun API server", () => {
+ const server = createDevServerConfig({
+ DASHBOARD_DEV_API_TARGET: "http://127.0.0.1:5174",
+ });
+
+ expect(server?.proxy?.["/api"]).toMatchObject({
+ target: "http://127.0.0.1:5174",
+ changeOrigin: true,
+ });
+ });
+});
diff --git a/src/server/dev.ts b/src/server/dev.ts
new file mode 100644
index 0000000..c515240
--- /dev/null
+++ b/src/server/dev.ts
@@ -0,0 +1,72 @@
+const webHost = process.env.HOST || "0.0.0.0";
+const webPort = process.env.PORT || "5173";
+const apiHost = process.env.DASHBOARD_DEV_API_HOST || "127.0.0.1";
+const apiPort = process.env.DASHBOARD_DEV_API_PORT || "5174";
+const apiTarget = `http://${apiHost}:${apiPort}`;
+
+if (import.meta.main) {
+ runDevServers();
+}
+
+export function runDevServers(): void {
+ const children: Array> = [];
+ let shuttingDown = false;
+
+ function spawn(
+ label: string,
+ command: string[],
+ env: Record = {},
+ ): void {
+ const child = Bun.spawn(command, {
+ env: {
+ ...process.env,
+ ...env,
+ },
+ stdin: "inherit",
+ stdout: "inherit",
+ stderr: "inherit",
+ });
+ children.push(child);
+
+ void child.exited.then((code) => {
+ if (shuttingDown) return;
+ console.error(`${label} exited with status ${code}`);
+ shutdown(code || 1);
+ });
+ }
+
+ function shutdown(code = 0): void {
+ if (shuttingDown) return;
+ shuttingDown = true;
+
+ for (const child of children) {
+ child.kill();
+ }
+
+ void Promise.allSettled(children.map((child) => child.exited)).then(() => {
+ process.exit(code);
+ });
+ }
+
+ process.on("SIGINT", () => shutdown(0));
+ process.on("SIGTERM", () => shutdown(0));
+
+ spawn("api server", [process.execPath, "src/server/index.ts"], {
+ HOST: apiHost,
+ PORT: apiPort,
+ });
+
+ spawn("vite dev server", [
+ process.execPath,
+ "x",
+ "vite",
+ "--host",
+ webHost,
+ "--port",
+ webPort,
+ ], {
+ DASHBOARD_DEV_API_TARGET: apiTarget,
+ });
+
+ console.info(`Dashboard API proxy target: ${apiTarget}`);
+}
diff --git a/vite.config.ts b/vite.config.ts
index 538be29..ceff746 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,6 +2,23 @@ import { fileURLToPath } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig, configDefaults } from "vitest/config";
+import type { UserConfig } from "vite";
+
+export function createDevServerConfig(
+ env: NodeJS.ProcessEnv = process.env,
+): UserConfig["server"] {
+ const apiTarget = env.DASHBOARD_DEV_API_TARGET;
+ if (!apiTarget) return undefined;
+
+ return {
+ proxy: {
+ "/api": {
+ target: apiTarget,
+ changeOrigin: true,
+ },
+ },
+ };
+}
export default defineConfig({
plugins: [react(), tailwindcss()],
@@ -10,6 +27,7 @@ export default defineConfig({
$lib: fileURLToPath(new URL("./src/lib", import.meta.url)),
},
},
+ server: createDevServerConfig(),
test: {
exclude: [...configDefaults.exclude, "tests/e2e/**"],
},