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/**"], },