diff --git a/.containerignore b/.containerignore index 1812091..70b617e 100644 --- a/.containerignore +++ b/.containerignore @@ -5,18 +5,8 @@ coverage data dist node_modules -out playwright-report storybook-static test-results .env .env.* -apps/*/.turbo -apps/*/build -apps/*/data -apps/*/dist -apps/*/playwright-report -apps/*/test-results -packages/*/.turbo -packages/*/dist -packages/*/storybook-static diff --git a/.forgejo/workflows/dimensionlab-website.yml b/.forgejo/workflows/dimensionlab-website.yml deleted file mode 100644 index 198a8aa..0000000 --- a/.forgejo/workflows/dimensionlab-website.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Dimension Lab website - -on: - pull_request: - types: - - opened - - synchronize - - reopened - push: - branches: - - main - workflow_dispatch: - -concurrency: - group: dimensionlab-website-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} - -jobs: - ci: - runs-on: docker - timeout-minutes: 30 - steps: - - name: Checkout - uses: https://data.forgejo.org/actions/checkout@v4 - with: - fetch-depth: 0 - submodules: false - - - name: Initialize submodules - run: | - git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/" - git submodule update --init --recursive - - - name: Install Bun - run: | - curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14" - "$HOME/.bun/bin/bun" --version - - - name: Check, test, and build - run: | - export BUN_INSTALL="$HOME/.bun" - export PATH="$BUN_INSTALL/bin:$PATH" - bun install --frozen-lockfile - bun run check - bun run test - bun run build - - deploy: - needs: ci - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - runs-on: deploy - timeout-minutes: 30 - steps: - - name: Checkout - run: | - if [ -d .git ]; then - git remote set-url origin git@git.dimensionlab.net:vince/dimensionlab-website.git - else - git init - git remote add origin git@git.dimensionlab.net:vince/dimensionlab-website.git - fi - git fetch --force --prune --depth=1 origin "$GITHUB_SHA" - git checkout --force --detach "$GITHUB_SHA" - git clean -ffdx - - - name: Initialize submodules - run: | - git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/" - git submodule update --init --recursive - - - name: Verify Podman deployment socket - run: | - command -v podman - command -v systemctl - unit="$(timeout 15s podman inspect dimensionlab-website --format '{{ index .Config.Labels "PODMAN_SYSTEMD_UNIT" }}')" - test "$unit" = "dimensionlab-website.service" - - - name: Deploy production website - env: - DEPLOY_CONTAINER_CLI: podman - DEPLOY_EVENT_NAME: ${{ github.event_name }} - DEPLOY_REF: ${{ github.ref }} - DEPLOY_RESTART_STRATEGY: quadlet-container - DEPLOY_SHA: ${{ github.sha }} - run: scripts/deploy-dimensionlab-website.sh diff --git a/.gitignore b/.gitignore index eed0de1..7d8d6c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,8 @@ node_modules/ -out/ .svelte-kit/ build/ dist/ .vite/ -.turbo/ -apps/*/.turbo/ -packages/*/.turbo/ .env .env.* @@ -14,8 +10,6 @@ packages/*/.turbo/ data/*.sqlite data/*.sqlite-* -apps/*/data/*.sqlite -apps/*/data/*.sqlite-* coverage/ playwright-report/ test-results/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 32d1c14..0000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "packages/ui"] - path = packages/ui - url = ssh://git@git.dimensionlab.net/vince/dimensionlab-ui.git - branch = main diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..cfd3a61 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,19 @@ +import type { StorybookConfig } from "@storybook/react-vite"; + +const config: StorybookConfig = { + stories: ["../src/**/*.stories.@(js|ts|tsx)"], + staticDirs: ["../static"], + addons: [ + "@storybook/addon-a11y", + "@storybook/addon-vitest", + ], + framework: { + name: "@storybook/react-vite", + options: {}, + }, + docs: { + autodocs: "tag", + }, +}; + +export default config; diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 0000000..964f47b --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,63 @@ +import "../src/app.css"; +import type { Preview } from "@storybook/react-vite"; +import { setupWorker } from "msw/browser"; +import { externalApiHandlers } from "../src/lib/testing/external-api-mocks"; + +if (typeof window !== "undefined") { + const worker = setupWorker(...externalApiHandlers); + void worker.start({ + onUnhandledRequest: "bypass", + serviceWorker: { + url: "/mockServiceWorker.js", + }, + }); +} + +const preview: Preview = { + decorators: [ + (Story, context) => { + const theme = context.globals.theme === "light" ? "light" : "dark"; + + if (typeof document !== "undefined") { + document.documentElement.setAttribute("data-ui-theme", theme); + } + + return Story(); + }, + ], + globalTypes: { + theme: { + description: "Dashboard component theme", + defaultValue: "dark", + toolbar: { + title: "Theme", + icon: "circlehollow", + items: [ + { value: "dark", title: "Dark" }, + { value: "light", title: "Light" }, + ], + dynamicTitle: true, + }, + }, + }, + parameters: { + backgrounds: { + default: "canvas", + values: [ + { name: "canvas", value: "#020302" }, + { name: "raised", value: "#0b0d0c" }, + { name: "light canvas", value: "#f3f5ed" }, + { name: "light raised", value: "#eef1e7" }, + ], + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + layout: "fullscreen", + }, +}; + +export default preview; diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..d19c2d0 --- /dev/null +++ b/Containerfile @@ -0,0 +1,31 @@ +FROM docker.io/oven/bun:1.3.14 AS deps + +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile + +FROM deps AS build + +COPY . . +RUN bun run build + +FROM docker.io/oven/bun:1.3.14 AS runtime + +WORKDIR /app +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=3000 +ENV DATABASE_URL=file:/data/dimensionlab.sqlite +ENV DASHBOARD_MIGRATIONS_DIR=/app/drizzle + +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile --production +COPY --from=build /app/build ./build +COPY --from=build /app/dist ./dist +COPY --from=build /app/drizzle ./drizzle + +RUN mkdir -p /data +VOLUME ["/data"] +EXPOSE 3000 + +CMD ["bun", "build/index.js"] diff --git a/README.md b/README.md index 85177c5..f39dcb5 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,15 @@ # Dimension Lab Website -Turbo/Bun workspace for the Dimension Lab system overview dashboard and its -reusable React component library. +Standalone React runtime for the Dimension Lab system overview dashboard. This project is not a Homepage customization and does not depend on Homepage runtime, frontend code, or configuration. The dashboard will be model-driven: -the reusable UI package stays content-free, the reusable dashboard model -package owns schema and validation, and environment-specific data lives in -validated dashboard model state inside the web app. - -## Workspace Layout - -- `apps/web`: Vite React website, Bun API server, model fixtures, Drizzle - persistence, Playwright e2e checks, and container build. -- `packages/dashboard-model`: reusable dashboard schema, validation, and - generic model fixtures shared by apps and tooling. -- `packages/ui`: Git submodule for the reusable dashboard React components, - design tokens, shadcn/radix primitives, generic fixtures, and Storybook. - Component source is grouped under `foundation`, `frames`, `operations`, and - `telemetry` domains. -- `docs/superpowers`: migration specs and execution plans used for this repo. +the reusable renderer stays content-free, while environment-specific data lives +in validated dashboard model state. ## Development ```sh -git submodule update --init --recursive bun install bun run dev ``` @@ -33,19 +18,18 @@ This MVP uses Drizzle with Bun SQLite for local file-backed persistence. ## Scripts -- `bun run dev`: start the web app dev runtime through Turbo. -- `bun run check`: run TypeScript checks in all workspaces. -- `bun run test`: run the unit test stage in all workspaces. +- `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. - `bun run test:e2e`: build and run Playwright browser smoke and QA checks. -- `bun run test:qa`: run the release gate through Turbo across check, unit, - build, Storybook, and e2e tasks. -- `bun run build`: build the UI package, production website, and Bun server. -- `bun run preview`: preview the production web build. -- `bun run storybook`: start the UI package component explorer on port 6006. -- `bun run build-storybook`: build the UI package static Storybook artifact. -- `bun run db:generate`: generate web app Drizzle migrations. -- `bun run db:check`: validate web app migration consistency. +- `bun run test:qa`: run the MVP release gate. +- `bun run build`: build the production app. +- `bun run preview`: preview the production build. +- `bun run storybook`: start the component explorer on port 6006. +- `bun run build-storybook`: build the static Storybook review artifact. +- `bun run db:generate`: generate Drizzle migrations from the server schema. +- `bun run db:check`: validate migration consistency. ## Persistence @@ -56,14 +40,14 @@ URL is: DATABASE_URL=file:./data/dimensionlab.sqlite ``` -SQLite files under `data/` and `apps/*/data/` are ignored. Drizzle schema lives -in `apps/web/src/lib/server/db/schema.ts`; tracked migrations live in -`apps/web/drizzle/`. Runtime startup applies the checked-in dashboard migrations -before reads or writes. If the app is launched from outside the web app tree, -set `DASHBOARD_MIGRATIONS_DIR` to the tracked migrations directory. The current -driver is `bun:sqlite`, which keeps this repo installable in the Bun workflow. -The store boundary is isolated so a later Postgres driver can replace the -SQLite connection without changing the dashboard model or renderer. +SQLite files under `data/` are ignored. Drizzle schema lives in +`src/lib/server/db/schema.ts`; tracked migrations live in `drizzle/`. Runtime +startup applies the checked-in dashboard migrations before reads or writes. If +the app is launched from outside the repo tree, set `DASHBOARD_MIGRATIONS_DIR` +to the tracked migrations directory. The current driver is `bun:sqlite`, which +keeps this repo installable in the Bun workflow. The store boundary is isolated +so a later Postgres driver can replace the SQLite connection without changing +the dashboard model or renderer. Stored dashboard documents pass through a version migration boundary before reads or writes; the MVP supports `dashboard.v1` and fails unsupported versions with an explicit migration error. @@ -71,26 +55,25 @@ with an explicit migration error. ## Seed Data The initial Dimension Lab dashboard lives in -`apps/web/src/lib/dashboard-seed/dimensionlab.ts` as validated model data. It -includes the first-screen telemetry, service groups, status strip, weather -module, Iconify icon identifiers, links, and datasource references. Values that -are not live yet are labeled as fallback values in the data so later datasource -adapters can replace them without changing presentation components. +`src/lib/model/fixtures/dimensionlab.ts` as validated model data. It includes +the first-screen telemetry, service groups, status strip, weather module, +Iconify icon identifiers, links, and datasource references. Values that are not +live yet are labeled as fallback values in the data so later datasource adapters +can replace them without changing presentation components. ## Runtime Shape -The browser app in `apps/web` 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 `apps/web/build/index.js` to serve -the Vite `apps/web/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 -Storybook lives with `packages/ui` and covers the reusable UI components with -generic fixtures only. Stories must not import environment-specific dashboard -content; the presentation layer accepts labels, values, icons, status, and links -through typed props. +Storybook covers the reusable UI components with generic fixtures only. Stories +must not import environment-specific dashboard content; the presentation layer +accepts labels, values, icons, status, and links through typed props. ## MVP QA Gate @@ -109,9 +92,7 @@ bun run test:qa The gate runs TypeScript checks, Vitest coverage for model, persistence, renderer, datasource mocks, and presentation boundaries, the production build, the static Storybook build, and Playwright desktop/mobile -smoke checks against the built adapter output. Turbo owns the release task -graph; app package scripts stay as leaf commands and do not re-run the QA -pipeline internally. Playwright also performs +smoke checks against the built adapter output. Playwright also performs baseline screenshot checks, keyboard navigation checks, reduced-motion checks, landmark checks, and axe accessibility checks against the real model-driven route. @@ -125,31 +106,27 @@ belong in validated model data, not reusable components. ## Deployment Notes -The production build emits Vite client assets under `apps/web/dist/` and a Bun -server entry at `apps/web/build/index.js`. A minimal deployment flow is: +The production build emits Vite client assets under `dist/` and a Bun server +entry at `build/index.js`. A minimal deployment flow is: ```sh -git submodule update --init --recursive bun install --frozen-lockfile bun run build -cd apps/web DATABASE_URL=file:/data/dimensionlab.sqlite HOST=0.0.0.0 PORT=3000 bun build/index.js ``` Mount `/data` or set `DATABASE_URL` to another persistent SQLite path. If the process starts outside the repository root, set `DASHBOARD_MIGRATIONS_DIR` to -the checked-in `apps/web/drizzle/` directory so startup migrations can run. +the checked-in `drizzle/` directory so startup migrations can run. ### Internal Container -The checked-in `apps/web/Containerfile` runs -`turbo prune @dimensionlab/web --docker`, installs the pruned manifest set, and -builds the React client plus Bun server from the pruned workspace source. For the -Dimension Lab internal host, run it behind Caddy on a loopback port and mount -persistent state at `/data`: +The checked-in `Containerfile` builds the React client and Bun server into a +runtime image. For the Dimension Lab internal host, run it behind Caddy on a +loopback port and mount persistent state at `/data`: ```sh -podman build -f apps/web/Containerfile -t localhost/dimensionlab-website:latest . +podman build -t localhost/dimensionlab-website:latest . podman run --rm \ --publish 127.0.0.1:25341:3000 \ --volume "$HOME/containers/dimensionlab-website/data:/data:Z" \ @@ -160,28 +137,4 @@ podman run --rm \ The env file must provide `AGENT_CONFIG_TOKEN`. Runtime defaults inside the image set `HOST=0.0.0.0`, `PORT=3000`, `DATABASE_URL=file:/data/dimensionlab.sqlite`, and -`DASHBOARD_MIGRATIONS_DIR=/repo/apps/web/drizzle`. - -### Forgejo Actions Deployment - -Merges to `main` run `.forgejo/workflows/dimensionlab-website.yml`. Pull -requests run check, test, and build only; the deploy job is guarded to run only -for `push` events on `refs/heads/main`. - -The workflow uses two runner classes. Pull request CI runs on the containerized -`docker` runner. Production deployment runs on a separate host runner with the -`deploy:host` label so the guarded deploy script can use the user's rootless -`podman` and `systemctl --user` commands directly. The deploy job uses a -shell-only `git fetch` checkout so the host runner does not need a Node runtime -for checkout actions. - -```yaml -runner: - labels: - - deploy:host -``` - -The deploy job also performs a host preflight against the -`dimensionlab-website.service` Podman label before it builds or restarts the -production container. Do not give the general pull request runner deployment -socket access; keep deploy privileges on the dedicated `deploy` runner. +`DASHBOARD_MIGRATIONS_DIR=/app/drizzle`. diff --git a/apps/web/Containerfile b/apps/web/Containerfile deleted file mode 100644 index 32387a4..0000000 --- a/apps/web/Containerfile +++ /dev/null @@ -1,39 +0,0 @@ -FROM docker.io/oven/bun:1.3.14 AS base - -WORKDIR /repo -ENV PATH=/repo/apps/web/node_modules/.bin:/repo/packages/dashboard-model/node_modules/.bin:/repo/packages/ui/node_modules/.bin:/repo/node_modules/.bin:$PATH - -FROM base AS pruner - -COPY . . -RUN bunx turbo prune @dimensionlab/web --docker - -FROM base AS deps - -COPY --from=pruner /repo/out/json/ ./ -RUN bun install --frozen-lockfile --ignore-scripts - -FROM deps AS build - -COPY --from=pruner /repo/out/full/ ./ -COPY --from=pruner /repo/tsconfig.base.json /repo/tsconfig.json ./ -RUN bun run build - -FROM base AS runtime - -WORKDIR /repo/apps/web -ENV NODE_ENV=production -ENV HOST=0.0.0.0 -ENV PORT=3000 -ENV DATABASE_URL=file:/data/dimensionlab.sqlite -ENV DASHBOARD_MIGRATIONS_DIR=/repo/apps/web/drizzle - -COPY --from=build /repo/apps/web/build ./build -COPY --from=build /repo/apps/web/dist ./dist -COPY --from=build /repo/apps/web/drizzle ./drizzle - -RUN mkdir -p /data -VOLUME ["/data"] -EXPOSE 3000 - -CMD ["bun", "build/index.js"] diff --git a/apps/web/package.json b/apps/web/package.json deleted file mode 100644 index 015fc55..0000000 --- a/apps/web/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@dimensionlab/web", - "version": "0.0.1", - "private": true, - "type": "module", - "scripts": { - "dev": "bun src/server/dev.ts", - "build": "rm -rf 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", - "check": "tsc --noEmit", - "test": "bun --bun vitest run", - "test:unit": "bun --bun vitest run", - "test:e2e": "env -u NO_COLOR playwright test", - "db:generate": "drizzle-kit generate", - "db:check": "drizzle-kit check" - }, - "dependencies": { - "@dimensionlab/dashboard-model": "workspace:*", - "@dimensionlab/ui": "workspace:*", - "@sinclair/typebox": "^0.34.49", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", - "drizzle-orm": "^0.45.2", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "tw-animate-css": "^1.4.0" - }, - "devDependencies": { - "@axe-core/playwright": "^4.11.3", - "@playwright/test": "^1.61.0", - "@tailwindcss/vite": "^4.3.1", - "@types/bun": "^1.3.14", - "@types/node": "^25.9.3", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "bun-types": "^1.3.14", - "drizzle-kit": "^0.31.10", - "msw": "^2.14.6", - "shadcn": "^4.11.0", - "tailwindcss": "^4.3.1", - "typescript": "^6.0.3", - "vite": "^8.0.16", - "vitest": "^4.1.9" - }, - "msw": { - "workerDirectory": [ - "static" - ] - } -} diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx deleted file mode 100644 index c1add67..0000000 --- a/apps/web/src/App.test.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { renderToString } from "react-dom/server"; -import { describe, expect, test } from "vitest"; -import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; -import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; -import { - AppStateView, - dashboardTileMatchKey, - dashboardHydrationTiles, - restoreDashboardTileSnapshots, -} from "./App"; - -describe("React app dashboard state view", () => { - test("renders loading dashboard state", () => { - const html = renderToString( - , - ); - - expect(html).toContain("Loading Dashboard"); - expect(html).toContain("Fetching active model"); - }); - - test("renders an accessible theme toggle with the active theme", () => { - const html = renderToString( - undefined} - />, - ); - - expect(html).toContain('data-ui-theme-toggle="true"'); - expect(html).toContain('data-ui-theme-current="dark"'); - expect(html).toContain('aria-label="Light theme"'); - expect(html).toContain('aria-pressed="false"'); - expect(html).toContain("Theme"); - expect(html).toContain("Dark"); - expect(html).toContain("Light"); - }); - - test("renders the dashboard shell while individual items hydrate", () => { - const html = renderToString( - , - ); - - expect(html).toContain("Operations Console"); - expect(html).toContain("Service Uptime"); - expect(html).toContain("Identity"); - expect(html).toContain("Environment"); - expect(html).not.toContain("Loading Dashboard"); - expect(html).toContain( - 'data-severity="loading" data-model-id="service-uptime"', - ); - expect(html).toContain('data-severity="loading" data-model-id="identity"'); - expect(html).toContain('data-severity="loading" data-model-id="ambient"'); - expect(html).toContain('data-severity="loading" data-model-id="runtime:status"'); - }); - - test("hydrates every status cell from the Dimension Lab shell", () => { - expect(dashboardHydrationTiles(dimensionLabDashboardFixture)).toContainEqual({ - kind: "status", - stripId: "footer-status", - id: "auto-refresh", - }); - }); - - test("prioritizes status, telemetry, modules, then services for hydration", () => { - const tiles = dashboardHydrationTiles(dimensionLabDashboardFixture); - const kinds = tiles.map((tile) => tile.kind); - const firstServiceIndex = kinds.indexOf("service"); - - expect(kinds.slice(0, 5)).toEqual([ - "status", - "status", - "status", - "status", - "status", - ]); - expect(kinds.lastIndexOf("telemetry")).toBeLessThan(kinds.indexOf("module")); - expect(kinds.lastIndexOf("module")).toBeLessThan(firstServiceIndex); - expect( - tiles.filter((tile) => tile.kind === "telemetry").map((tile) => tile.id), - ).toEqual( - dimensionLabDashboardFixture.telemetry - .filter((card) => card.datasource?.type === "external") - .map((card) => card.id), - ); - expect( - tiles.filter((tile) => tile.kind === "service").map((tile) => ({ - groupId: tile.groupId, - id: tile.id, - })), - ).toEqual( - dimensionLabDashboardFixture.serviceGroups.flatMap((group) => - group.services - .filter((service) => service.datasource?.type === "external") - .map((service) => ({ - groupId: group.id, - id: service.id, - })) - ), - ); - }); - - test("applies restored tile snapshots without marking them as loading", () => { - const restored = restoreDashboardTileSnapshots( - { - state: "ready", - document: dimensionLabDashboardFixture, - schemaVersion: "dashboard.v1", - currentRevisionId: "revision-a", - }, - [ - { - ageMs: 60_000, - response: { - state: "ready", - tile: { kind: "telemetry", id: "infra-ram" }, - item: { - ...dimensionLabDashboardFixture.telemetry[0], - detail: "cached - stale 60s", - severity: "stale", - }, - }, - }, - ], - ); - - expect(restored.restoredItemIds).toEqual(new Set(["telemetry:infra-ram"])); - if (restored.dashboard.state !== "ready") { - throw new Error("Expected dashboard to be ready"); - } - expect(restored.dashboard.document.telemetry[0]).toMatchObject({ - id: "infra-ram", - detail: "cached - stale 60s", - severity: "stale", - }); - }); - - test("does not restore aggregate health snapshots over the fresh shell", () => { - const restored = restoreDashboardTileSnapshots( - { - state: "ready", - document: dimensionLabDashboardFixture, - schemaVersion: "dashboard.v1", - currentRevisionId: "revision-a", - }, - [ - { - ageMs: 60_000, - response: { - state: "ready", - tile: { kind: "status", stripId: "footer-status", id: "system-status" }, - item: { - id: "system-status", - label: "System Status", - value: "20 services down", - severity: "stale", - }, - }, - }, - { - ageMs: 60_000, - response: { - state: "ready", - tile: { kind: "module", id: "runtime-health-summary" }, - item: { - id: "runtime-health-summary", - kind: "summary", - title: "Runtime Health", - value: "20 services down", - detail: "0 warnings - 8 services ok - stale 60s", - severity: "stale", - }, - }, - }, - ], - ); - - expect(restored.restoredItemIds).toEqual(new Set()); - if (restored.dashboard.state !== "ready") { - throw new Error("Expected dashboard to be ready"); - } - expect( - restored.dashboard.document.statusStrips[0].items.find((item) => - item.id === "system-status" - ), - ).toMatchObject({ - id: "system-status", - value: "Fallback operational", - }); - expect( - restored.dashboard.document.modules?.find((module) => - module.id === "runtime-health-summary" - ), - ).toMatchObject({ - id: "runtime-health-summary", - value: "fallback", - }); - }); - - test("uses structured tile match keys for delimiter-bearing ids", () => { - expect( - dashboardTileMatchKey({ kind: "service", groupId: "a:b", id: "c" }), - ).not.toBe( - dashboardTileMatchKey({ kind: "service", groupId: "a", id: "b:c" }), - ); - expect( - dashboardTileMatchKey({ kind: "status", stripId: "a:b", id: "c" }), - ).not.toBe( - dashboardTileMatchKey({ kind: "status", stripId: "a", id: "b:c" }), - ); - }); -}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx deleted file mode 100644 index 403bef9..0000000 --- a/apps/web/src/App.tsx +++ /dev/null @@ -1,875 +0,0 @@ -import { useEffect, useState } from "react"; -import type { - DashboardDocument, - DashboardModule, - ServiceEntry, - StatusItem, - TelemetryCard, -} from "@dimensionlab/dashboard-model"; -import type { DashboardRuntimeState } from "$lib/server/dashboard"; -import { - attachDashboardRefreshLifecycle, - collectVisibleDashboardModelIds, - createDashboardPerformanceMarks, - createDashboardRefreshDelay, - createDashboardTileSnapshotStore, - createDashboardTileBackoff, - createDashboardRequestAborter, - isPersistableDashboardTileSnapshot, - runViewportAwareDashboardHydrationQueue, - shouldPauseDashboardRefresh, - subscribeToDashboardTileEvents, - waitForDashboardHydrationIdle, - type DashboardIntersectionObserverFactory, - type DashboardTileReference, - type DashboardTileSnapshotStoreContext, - type RestoredDashboardTileSnapshot, -} from "$lib/client/dashboard-refresh"; -import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer"; -import { - DashboardFrame, - SystemState, - ThemeToggle, - persistUiTheme, - resolveInitialUiTheme, - type UiTheme, - type UiSeverity, - type UiDashboardPreview, -} from "@dimensionlab/ui"; - -type DashboardTileResponse = - | { - state: "ready"; - tile: DashboardTileReference; - item: DashboardModule | ServiceEntry | StatusItem | TelemetryCard; - } - | { - state: "not_found"; - tile: DashboardTileReference; - message: string; - } - | { - state: "disabled"; - tile: DashboardTileReference; - message: string; - }; - -type DashboardTilesBatchResponse = { - state: "ready"; - tiles: DashboardTileResponse[]; -}; - -type DashboardTileHydrationResult = "aborted" | "failed" | "ready"; - -const dashboardTileHydrationConcurrency = 6; -const dashboardFallbackRefreshIntervalMs = 30_000; -const dashboardViewportObservationTimeoutMs = 80; -type DashboardTileSnapshotStore = ReturnType; - -export function AppStateView({ - dashboard, - onThemeChange, - theme, - hydratingItemIds, -}: { - dashboard: DashboardRuntimeState; - hydratingItemIds?: ReadonlySet; - onThemeChange?: (theme: UiTheme) => void; - theme?: UiTheme; -}) { - const themeToggle = - theme && onThemeChange ? ( - - ) : null; - - if (dashboard.state === "ready") { - const uiDashboard = markHydratingItems( - dashboardDocumentToUiDashboard(dashboard.document), - hydratingItemIds, - ); - - return ( - - ); - } - - const detail = `${dashboard.subtitle}: ${dashboard.message}`; - const errors = dashboard.state === "invalid" ? dashboard.errors : []; - - return ( -
- {themeToggle ? ( -
{themeToggle}
- ) : null} - - {errors.length ? ( -
    - {errors.map((error) => ( -
  • {error}
  • - ))} -
- ) : null} -
- ); -} - -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(null); - const [hydratingItemIds, setHydratingItemIds] = useState>( - () => new Set(), - ); - const [theme, setTheme] = useState(() => { - if (typeof window === "undefined") return "dark"; - - return resolveInitialUiTheme(getThemeStorage()); - }); - - useEffect(() => { - if (!dashboard) return; - - 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(() => { - document.documentElement.dataset.uiTheme = theme; - persistUiTheme(theme, getThemeStorage()); - }, [theme]); - - useEffect(() => { - let cancelled = false; - let refreshTimer: number | undefined; - let unsubscribeTileEvents: (() => void) | undefined; - let lastRefreshIntervalMs = dashboardFallbackRefreshIntervalMs; - let hydrationRun = 0; - const requestAborter = createDashboardRequestAborter(); - const performanceMarks = createDashboardPerformanceMarks(); - const refreshDelay = createDashboardRefreshDelay(); - const tileBackoff = createDashboardTileBackoff(); - const tileSnapshotStore = createDashboardTileSnapshotStore( - getTileSnapshotStorage(), - ); - - function refreshPaused() { - return shouldPauseDashboardRefresh({ - visibilityState: document.visibilityState, - online: navigator.onLine, - }); - } - - function clearRefreshTimer() { - if (refreshTimer !== undefined) { - window.clearTimeout(refreshTimer); - refreshTimer = undefined; - } - } - - function scheduleNextRefresh(baseDelayMs: number) { - clearRefreshTimer(); - if (cancelled || refreshPaused()) return; - - refreshTimer = window.setTimeout(() => { - refreshTimer = undefined; - void loadDashboard(); - }, refreshDelay.nextDelayMs(baseDelayMs)); - } - - function pauseRefreshes() { - clearRefreshTimer(); - unsubscribeTileEvents?.(); - unsubscribeTileEvents = undefined; - requestAborter.abortActiveRequests(); - setHydratingItemIds(new Set()); - } - - async function loadDashboard() { - if (cancelled || refreshPaused()) return; - - clearRefreshTimer(); - const shellSignal = requestAborter.beginShellRun(); - - try { - const response = await fetch("/api/dashboard", { signal: shellSignal }); - const nextDashboard = (await response.json()) as DashboardRuntimeState; - - if (cancelled || shellSignal.aborted || refreshPaused()) return; - refreshDelay.recordSuccess(); - performanceMarks.markShellLoad(); - const restored = restoreDashboardTileSnapshots( - nextDashboard, - nextDashboard.state === "ready" - ? tileSnapshotStore.restore({ - currentRevisionId: nextDashboard.currentRevisionId, - schemaVersion: nextDashboard.schemaVersion, - }) - : [], - ); - setDashboard(restored.dashboard); - - const currentRun = ++hydrationRun; - if ( - restored.dashboard.state === "ready" && - restored.dashboard.liveDatasourceHydration?.enabled !== false - ) { - const tiles = dashboardHydrationTiles(restored.dashboard.document).filter( - (tile) => tileBackoff.canAttempt(dashboardTileKey(tile)), - ); - const tileSignal = requestAborter.beginTileRun(); - setHydratingItemIds( - new Set( - tiles - .map(dashboardTileKey) - .filter((key) => !restored.restoredItemIds.has(key)), - ), - ); - hydrateDashboardTiles( - tiles, - currentRun, - tileSignal, - { - currentRevisionId: restored.dashboard.currentRevisionId, - schemaVersion: restored.dashboard.schemaVersion, - }, - ); - subscribeDashboardTileEvents( - currentRun, - tileSignal, - { - currentRevisionId: restored.dashboard.currentRevisionId, - schemaVersion: restored.dashboard.schemaVersion, - }, - ); - } else { - setHydratingItemIds(new Set()); - } - - const refreshIntervalSeconds = - nextDashboard.state === "ready" - ? nextDashboard.document.metadata.refreshIntervalSeconds - : undefined; - - if (refreshIntervalSeconds && !refreshPaused()) { - lastRefreshIntervalMs = refreshIntervalSeconds * 1000; - scheduleNextRefresh(lastRefreshIntervalMs); - } - } catch (error) { - if (!isAbortError(error) && !cancelled) { - refreshDelay.recordFailure(); - console.error("Dashboard refresh failed", error); - scheduleNextRefresh(lastRefreshIntervalMs); - } - } - } - - function hydrateDashboardTiles( - tiles: DashboardTileReference[], - run: number, - signal: AbortSignal, - snapshotContext: DashboardTileSnapshotStoreContext, - ) { - const modelIds = tiles.map(dashboardTileModelId); - - void runViewportAwareDashboardHydrationQueue({ - batchSize: dashboardTileHydrationConcurrency, - collectVisibleModelIds: async () => { - await waitForDashboardRenderFrame(signal); - return collectVisibleDashboardModelIds({ - clearTimeout: window.clearTimeout.bind(window), - createObserver: getDashboardIntersectionObserverFactory(), - documentTarget: document, - modelIds, - setTimeout: window.setTimeout.bind(window), - signal, - timeoutMs: dashboardViewportObservationTimeoutMs, - }); - }, - concurrency: dashboardTileHydrationConcurrency, - getModelId: dashboardTileModelId, - hydrate: async (tile) => { - const key = dashboardTileKey(tile); - const result = await hydrateDashboardTile( - tile, - run, - signal, - snapshotContext, - ); - - if (result === "ready") { - tileBackoff.recordSuccess(key); - } else if (result === "failed") { - tileBackoff.recordFailure(key); - } - }, - hydrateBatch: async (batch) => { - const results = await hydrateDashboardTileBatch( - batch, - run, - signal, - snapshotContext, - ); - - for (const { result, tile } of results) { - const key = dashboardTileKey(tile); - if (result === "ready") { - tileBackoff.recordSuccess(key); - } else if (result === "failed") { - tileBackoff.recordFailure(key); - } - } - }, - items: tiles, - onAllItemsSettled: () => performanceMarks.markAllTilesSettled(), - onVisibleItemsSettled: () => performanceMarks.markVisibleTilesReady(), - signal, - waitForIdle: () => - waitForDashboardHydrationIdle({ - ...getDashboardIdleCallbacks(), - clearTimeout: window.clearTimeout.bind(window), - setTimeout: window.setTimeout.bind(window), - signal, - }), - }); - } - - function subscribeDashboardTileEvents( - run: number, - signal: AbortSignal, - snapshotContext: DashboardTileSnapshotStoreContext, - ) { - unsubscribeTileEvents?.(); - unsubscribeTileEvents = subscribeToDashboardTileEvents({ - onTile: (event) => { - const tileResponse = event as DashboardTileResponse; - if (!isDashboardTileResponse(tileResponse)) return; - applyDashboardTileHydrationResponse( - tileResponse.tile, - tileResponse, - run, - signal, - snapshotContext, - ); - }, - onUnavailable: () => { - unsubscribeTileEvents = undefined; - }, - }); - } - - async function hydrateDashboardTileBatch( - tiles: DashboardTileReference[], - run: number, - signal: AbortSignal, - snapshotContext: DashboardTileSnapshotStoreContext, - ): Promise> { - try { - const response = await fetch("/api/dashboard/tiles", { - body: JSON.stringify({ tiles }), - headers: { "Content-Type": "application/json" }, - method: "POST", - signal, - }); - const batchResponse = (await response.json()) as DashboardTilesBatchResponse; - - if ( - cancelled || - signal.aborted || - run !== hydrationRun || - !response.ok || - batchResponse.state !== "ready" - ) { - throw new Error("Dashboard tile batch hydration failed"); - } - - const responsesByKey = new Map( - batchResponse.tiles.map((tileResponse) => [ - dashboardTileMatchKey(tileResponse.tile), - tileResponse, - ]), - ); - - return tiles.map((tile) => { - const key = dashboardTileKey(tile); - try { - return { - tile, - result: applyDashboardTileHydrationResponse( - tile, - responsesByKey.get(dashboardTileMatchKey(tile)), - run, - signal, - snapshotContext, - ), - }; - } finally { - finishDashboardTileHydration(key, run, signal); - } - }); - } catch (error) { - if (signal.aborted || cancelled || run !== hydrationRun) { - return tiles.map((tile) => ({ result: "aborted", tile })); - } - - return Promise.all( - tiles.map(async (tile) => ({ - tile, - result: await hydrateDashboardTile(tile, run, signal, snapshotContext), - })), - ); - } - } - - async function hydrateDashboardTile( - tile: DashboardTileReference, - run: number, - signal: AbortSignal, - snapshotContext: DashboardTileSnapshotStoreContext, - ): Promise { - const key = dashboardTileKey(tile); - - try { - const response = await fetch(dashboardTileUrl(tile), { signal }); - const tileResponse = (await response.json()) as DashboardTileResponse; - - if (!response.ok) { - return "failed"; - } - - const result = applyDashboardTileHydrationResponse( - tile, - tileResponse, - run, - signal, - snapshotContext, - ); - if (result !== "ready") { - return signal.aborted || cancelled || run !== hydrationRun - ? "aborted" - : "failed"; - } - - return "ready"; - } catch (error) { - if (!isAbortError(error) && !cancelled) { - console.error("Dashboard tile hydration failed", error); - return "failed"; - } - return "aborted"; - } finally { - finishDashboardTileHydration(key, run, signal); - } - } - - function applyDashboardTileHydrationResponse( - tile: DashboardTileReference, - tileResponse: DashboardTileResponse | undefined, - run: number, - signal: AbortSignal, - snapshotContext: DashboardTileSnapshotStoreContext, - ): DashboardTileHydrationResult { - if ( - cancelled || - signal.aborted || - run !== hydrationRun || - !tileResponse || - dashboardTileMatchKey(tileResponse.tile) !== dashboardTileMatchKey(tile) || - tileResponse.state !== "ready" - ) { - return signal.aborted || cancelled || run !== hydrationRun - ? "aborted" - : "failed"; - } - - const readyTileResponse = tileResponse; - setDashboard((current) => - current?.state === "ready" - ? { - ...current, - document: applyDashboardTile(current.document, readyTileResponse), - } - : current, - ); - tileSnapshotStore.saveReadyTile({ - ...snapshotContext, - response: readyTileResponse, - }); - performanceMarks.markFirstTileReady(); - return "ready"; - } - - function finishDashboardTileHydration( - key: string, - run: number, - signal: AbortSignal, - ) { - if (!cancelled && !signal.aborted && run === hydrationRun) { - setHydratingItemIds((current) => { - const next = new Set(current); - next.delete(key); - return next; - }); - } - } - - const detachRefreshLifecycle = attachDashboardRefreshLifecycle({ - documentTarget: document, - windowTarget: window, - loadDashboard, - pauseRefreshes, - refreshPaused: () => cancelled || refreshPaused(), - }); - - void loadDashboard(); - - return () => { - cancelled = true; - clearRefreshTimer(); - unsubscribeTileEvents?.(); - requestAborter.abortActiveRequests(); - detachRefreshLifecycle(); - }; - }, []); - - return dashboard ? ( - - ) : null; -} - -function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity { - if (state === "invalid") return "danger"; - if (state === "loading") return "loading"; - return "stale"; -} - -function stateIcon(state: DashboardRuntimeState["state"]): string { - if (state === "invalid") return "mdi:file-alert-outline"; - if (state === "loading") return "mdi:progress-clock"; - return "mdi:tray"; -} - -function getThemeStorage(): Storage | undefined { - try { - return window.localStorage; - } catch { - return undefined; - } -} - -function getTileSnapshotStorage(): Storage | undefined { - try { - return window.localStorage; - } catch { - return undefined; - } -} - -type DashboardIdleWindow = Window & { - cancelIdleCallback?: (handle: number) => void; - requestIdleCallback?: ( - callback: () => void, - options?: { timeout?: number }, - ) => number; -}; - -function getDashboardIdleCallbacks() { - const idleWindow = window as DashboardIdleWindow; - return { - cancelIdleCallback: idleWindow.cancelIdleCallback?.bind(idleWindow), - requestIdleCallback: idleWindow.requestIdleCallback?.bind(idleWindow), - }; -} - -function getDashboardIntersectionObserverFactory(): - | DashboardIntersectionObserverFactory - | undefined { - if (typeof window.IntersectionObserver === "undefined") return undefined; - - return (callback) => - new window.IntersectionObserver((entries) => { - callback(entries); - }); -} - -async function waitForDashboardRenderFrame(signal: AbortSignal): Promise { - if (signal.aborted) return; - - await new Promise((resolve) => { - let settled = false; - let frame: number | undefined; - - function finish() { - if (settled) return; - settled = true; - if (frame !== undefined) window.cancelAnimationFrame(frame); - signal.removeEventListener("abort", finish); - resolve(); - } - - signal.addEventListener("abort", finish, { once: true }); - frame = window.requestAnimationFrame(finish); - }); -} - -function isAbortError(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "name" in error && - error.name === "AbortError" - ); -} - -function isDashboardTileResponse(value: unknown): value is DashboardTileResponse { - return ( - typeof value === "object" && - value !== null && - "state" in value && - (value.state === "ready" || - value.state === "not_found" || - value.state === "disabled") && - "tile" in value && - typeof value.tile === "object" && - value.tile !== null - ); -} - -export function restoreDashboardTileSnapshots( - dashboard: DashboardRuntimeState, - snapshots: RestoredDashboardTileSnapshot[], -): { - dashboard: DashboardRuntimeState; - restoredItemIds: Set; -} { - if (dashboard.state !== "ready" || snapshots.length === 0) { - return { - dashboard, - restoredItemIds: new Set(), - }; - } - - return snapshots.filter((snapshot) => - isPersistableDashboardTileSnapshot(snapshot.response.tile) - ).reduce( - (current, snapshot) => ({ - dashboard: { - ...current.dashboard, - document: applyDashboardTile( - current.dashboard.document, - snapshot.response as Extract, - ), - }, - restoredItemIds: new Set([ - ...current.restoredItemIds, - dashboardTileKey(snapshot.response.tile), - ]), - }), - { - dashboard, - restoredItemIds: new Set(), - }, - ); -} - -function markHydratingItems( - dashboard: UiDashboardPreview, - hydratingItemIds?: ReadonlySet, -): UiDashboardPreview { - if (!hydratingItemIds?.size) return dashboard; - - return { - ...dashboard, - telemetry: dashboard.telemetry.map((card) => - hydratingItemIds.has(`telemetry:${card.id}`) - ? { - ...card, - severity: "loading", - detail: "loading live telemetry", - } - : card, - ), - serviceGroups: dashboard.serviceGroups.map((group) => ({ - ...group, - services: group.services.map((service) => - hydratingItemIds.has(`service:${group.id}:${service.id}`) - ? { - ...service, - severity: "loading", - detail: "loading", - } - : service, - ), - })), - modules: dashboard.modules.map((module) => - hydratingItemIds.has(`module:${module.id}`) - ? { - ...module, - severity: "loading", - detail: "loading live data", - } - : module, - ), - statusItems: dashboard.statusItems.map((item) => - hydratingItemIds.has(`status:${item.id}`) - ? { - ...item, - severity: "loading", - value: "loading", - } - : item, - ), - }; -} - -export function dashboardHydrationTiles( - document: DashboardDocument, -): DashboardTileReference[] { - const telemetry = document.telemetry - .filter((card) => card.datasource?.type === "external") - .map((card): DashboardTileReference => ({ kind: "telemetry", id: card.id })); - const services = document.serviceGroups.flatMap((group) => - group.services - .filter((service) => service.datasource?.type === "external") - .map((service): DashboardTileReference => ({ - kind: "service", - groupId: group.id, - id: service.id, - })), - ); - const modules = (document.modules || []) - .filter((module) => - module.datasource?.type === "external" || - module.id === "runtime-health-summary" - ) - .map((module): DashboardTileReference => ({ kind: "module", id: module.id })); - const status = document.statusStrips.flatMap((strip) => - strip.items - .map((item): DashboardTileReference => ({ - kind: "status", - stripId: strip.id, - id: item.id, - })), - ); - - return [...status, ...telemetry, ...modules, ...services]; -} - -function dashboardTileKey(tile: DashboardTileReference): string { - if (tile.kind === "status") return `${tile.kind}:${tile.stripId}:${tile.id}`; - if (tile.kind === "service") return `${tile.kind}:${tile.groupId}:${tile.id}`; - return `${tile.kind}:${tile.id}`; -} - -export function dashboardTileMatchKey(tile: DashboardTileReference): string { - return JSON.stringify(tile); -} - -function dashboardTileModelId(tile: DashboardTileReference): string { - if (tile.kind === "status") return `${tile.stripId}:${tile.id}`; - return tile.id; -} - -function dashboardTileUrl(tile: DashboardTileReference): string { - const parts = tile.kind === "status" - ? ["api", "dashboard", "tile", tile.kind, tile.stripId, tile.id] - : tile.kind === "service" - ? ["api", "dashboard", "tile", tile.kind, tile.groupId, tile.id] - : ["api", "dashboard", "tile", tile.kind, tile.id]; - return `/${parts.map(encodeURIComponent).join("/")}`; -} - -function applyDashboardTile( - document: DashboardDocument, - response: Extract, -): DashboardDocument { - if (response.tile.kind === "telemetry") { - return { - ...document, - telemetry: document.telemetry.map((card) => - card.id === response.tile.id ? response.item as TelemetryCard : card, - ), - }; - } - - if (response.tile.kind === "service") { - const tile = response.tile; - return { - ...document, - serviceGroups: document.serviceGroups.map((group) => ({ - ...group, - services: group.id === tile.groupId - ? group.services.map((service) => - service.id === tile.id ? response.item as ServiceEntry : service, - ) - : group.services, - })), - }; - } - - if (response.tile.kind === "module") { - return { - ...document, - modules: (document.modules || []).map((module) => - module.id === response.tile.id ? response.item as DashboardModule : module, - ), - }; - } - - const tile = response.tile; - return { - ...document, - statusStrips: document.statusStrips.map((strip) => - strip.id === tile.stripId - ? { - ...strip, - items: strip.items.map((item) => - item.id === tile.id ? response.item as StatusItem : item, - ), - } - : strip, - ), - }; -} diff --git a/apps/web/src/lib/client/dashboard-refresh.test.ts b/apps/web/src/lib/client/dashboard-refresh.test.ts deleted file mode 100644 index 77ea795..0000000 --- a/apps/web/src/lib/client/dashboard-refresh.test.ts +++ /dev/null @@ -1,633 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - attachDashboardRefreshLifecycle, - collectVisibleDashboardModelIds, - createDashboardPerformanceMarks, - createDashboardRefreshDelay, - dashboardPerformanceMarks, - createDashboardTileSnapshotStore, - createDashboardTileBackoff, - createDashboardRequestAborter, - runDashboardHydrationQueue, - runDashboardHydrationBatchQueue, - runViewportAwareDashboardHydrationQueue, - shouldPauseDashboardRefresh, - splitDashboardHydrationItemsByVisibility, - subscribeToDashboardTileEvents, - waitForDashboardHydrationIdle, - type DashboardIntersectionEntry, -} from "./dashboard-refresh"; - -describe("dashboard refresh lifecycle", () => { - test("pauses refreshes when the document is hidden or the browser is offline", () => { - expect( - shouldPauseDashboardRefresh({ visibilityState: "visible", online: true }), - ).toBe(false); - expect( - shouldPauseDashboardRefresh({ visibilityState: "hidden", online: true }), - ).toBe(true); - expect( - shouldPauseDashboardRefresh({ visibilityState: "visible", online: false }), - ).toBe(true); - }); - - test("aborts shell and tile requests when a new shell run starts", () => { - const aborter = createDashboardRequestAborter(); - const shellSignal = aborter.beginShellRun(); - const tileSignal = aborter.beginTileRun(); - - const nextShellSignal = aborter.beginShellRun(); - - expect(shellSignal.aborted).toBe(true); - expect(tileSignal.aborted).toBe(true); - expect(nextShellSignal.aborted).toBe(false); - }); - - test("aborts active requests when refreshes are paused", () => { - const aborter = createDashboardRequestAborter(); - const shellSignal = aborter.beginShellRun(); - const tileSignal = aborter.beginTileRun(); - - aborter.abortActiveRequests(); - - expect(shellSignal.aborted).toBe(true); - expect(tileSignal.aborted).toBe(true); - }); - - test("pauses on hidden, offline, and pagehide events then resumes immediately when visible", () => { - const documentTarget = new EventTarget(); - const windowTarget = new EventTarget(); - const calls: string[] = []; - let paused = true; - - const detach = attachDashboardRefreshLifecycle({ - documentTarget, - windowTarget, - loadDashboard: () => { - calls.push("load"); - }, - pauseRefreshes: () => { - calls.push("pause"); - }, - refreshPaused: () => paused, - }); - - documentTarget.dispatchEvent(new Event("visibilitychange")); - windowTarget.dispatchEvent(new Event("offline")); - windowTarget.dispatchEvent(new Event("pagehide")); - - paused = false; - documentTarget.dispatchEvent(new Event("visibilitychange")); - windowTarget.dispatchEvent(new Event("online")); - - detach(); - documentTarget.dispatchEvent(new Event("visibilitychange")); - windowTarget.dispatchEvent(new Event("online")); - - expect(calls).toEqual(["pause", "pause", "pause", "load", "load"]); - }); - - test("limits tile hydration concurrency", async () => { - let active = 0; - let maxActive = 0; - const started: number[] = []; - const releases = new Map void>(); - - const queue = runDashboardHydrationQueue({ - concurrency: 2, - items: [1, 2, 3, 4], - signal: new AbortController().signal, - hydrate: async (item) => { - active += 1; - maxActive = Math.max(maxActive, active); - started.push(item); - await new Promise((resolve) => releases.set(item, resolve)); - active -= 1; - }, - }); - - await waitFor(() => started.length === 2); - expect(started).toEqual([1, 2]); - expect(maxActive).toBe(2); - - releases.get(1)?.(); - await waitFor(() => started.length === 3); - expect(started).toEqual([1, 2, 3]); - expect(maxActive).toBe(2); - - releases.get(2)?.(); - releases.get(3)?.(); - await waitFor(() => started.length === 4); - releases.get(4)?.(); - await queue; - expect(maxActive).toBe(2); - }); - - test("hydrates queued work in fixed-size batches", async () => { - const batches: number[][] = []; - - await runDashboardHydrationBatchQueue({ - batchSize: 3, - hydrateBatch: (items) => { - batches.push(items); - }, - items: [1, 2, 3, 4, 5, 6, 7], - signal: new AbortController().signal, - }); - - expect(batches).toEqual([ - [1, 2, 3], - [4, 5, 6], - [7], - ]); - }); - - test("hydrates visible items before deferred items and waits for idle", async () => { - const calls: string[] = []; - const idleReleases: Array<() => void> = []; - - const hydration = runViewportAwareDashboardHydrationQueue({ - collectVisibleModelIds: async () => new Set(["b"]), - concurrency: 1, - getModelId: (item) => item, - hydrate: async (item) => { - calls.push(item); - }, - items: ["a", "b", "c"], - onAllItemsSettled: () => calls.push("all-settled"), - onVisibleItemsSettled: () => calls.push("visible-settled"), - signal: new AbortController().signal, - waitForIdle: async () => { - calls.push("idle"); - await new Promise((resolve) => idleReleases.push(resolve)); - }, - }); - - await waitFor(() => calls.includes("idle")); - expect(calls).toEqual(["b", "visible-settled", "idle"]); - - idleReleases.shift()?.(); - await hydration; - expect(calls).toEqual([ - "b", - "visible-settled", - "idle", - "a", - "c", - "all-settled", - ]); - }); - - test("splits visible hydration items while preserving document order", () => { - expect(splitDashboardHydrationItemsByVisibility({ - getModelId: (item) => item.id, - items: [{ id: "status" }, { id: "telemetry" }, { id: "service" }], - visibleModelIds: new Set(["service", "status"]), - })).toEqual({ - visible: [{ id: "status" }, { id: "service" }], - deferred: [{ id: "telemetry" }], - }); - }); - - test("collects visible data-model-id elements with IntersectionObserver", async () => { - const elements = [ - modelElement("status"), - modelElement("telemetry"), - modelElement("unrelated"), - ]; - let callback: - | ((entries: DashboardIntersectionEntry[]) => void) - | undefined; - let finishObservation: (() => void) | undefined; - let disconnected = false; - const observed: string[] = []; - - const visible = collectVisibleDashboardModelIds({ - createObserver: (observerCallback) => { - callback = observerCallback; - return { - disconnect() { - disconnected = true; - }, - observe(element) { - observed.push(element.getAttribute("data-model-id") || ""); - }, - }; - }, - documentTarget: { - querySelectorAll: () => elements, - }, - modelIds: ["status", "telemetry"], - setTimeout: (handler) => { - finishObservation = handler; - return 1 as unknown as ReturnType; - }, - clearTimeout: () => undefined, - signal: new AbortController().signal, - }); - - callback?.([ - { - isIntersecting: false, - intersectionRatio: 0, - target: elements[0], - }, - { - isIntersecting: true, - target: elements[1], - }, - ]); - finishObservation?.(); - - expect(await visible).toEqual(new Set(["telemetry"])); - expect(observed).toEqual(["status", "telemetry"]); - expect(disconnected).toBe(true); - }); - - test("waits for requestIdleCallback when available", async () => { - let idleCallback: (() => void) | undefined; - let cancelledIdle: number | undefined; - const wait = waitForDashboardHydrationIdle({ - cancelIdleCallback: (handle) => { - cancelledIdle = handle; - }, - requestIdleCallback: (callback) => { - idleCallback = callback; - return 7; - }, - signal: new AbortController().signal, - }); - - expect(cancelledIdle).toBeUndefined(); - idleCallback?.(); - await wait; - expect(cancelledIdle).toBe(7); - }); - - test("jitters refresh delays and slows repeated failures", () => { - const delay = createDashboardRefreshDelay({ - jitterRatio: 0.1, - random: () => 1, - }); - - expect(delay.nextDelayMs(1_000)).toBe(1_100); - delay.recordFailure(); - expect(delay.nextDelayMs(1_000)).toBe(2_200); - delay.recordFailure(); - expect(delay.nextDelayMs(1_000)).toBe(4_400); - delay.recordSuccess(); - expect(delay.nextDelayMs(1_000)).toBe(1_100); - }); - - test("marks dashboard performance milestones once per shell run", () => { - const marks: string[] = []; - const performanceMarks = createDashboardPerformanceMarks({ - mark: (name) => marks.push(name), - }); - - performanceMarks.markShellLoad(); - performanceMarks.markFirstTileReady(); - performanceMarks.markFirstTileReady(); - performanceMarks.markVisibleTilesReady(); - performanceMarks.markAllTilesSettled(); - performanceMarks.markShellLoad(); - performanceMarks.markFirstTileReady(); - - expect(marks).toEqual([ - dashboardPerformanceMarks.shellLoad, - dashboardPerformanceMarks.firstTileReady, - dashboardPerformanceMarks.visibleTilesReady, - dashboardPerformanceMarks.allTilesSettled, - dashboardPerformanceMarks.shellLoad, - dashboardPerformanceMarks.firstTileReady, - ]); - }); - - test("subscribes to dashboard tile events", () => { - const received: unknown[] = []; - let listener: ((event: MessageEvent) => void) | undefined; - let closed = false; - - const unsubscribe = subscribeToDashboardTileEvents({ - createEventSource: (url) => { - expect(url).toBe("/api/dashboard/events"); - return { - addEventListener(_type, eventListener) { - listener = eventListener; - }, - close() { - closed = true; - }, - onerror: null, - }; - }, - onTile: (tile) => received.push(tile), - }); - - listener?.({ data: JSON.stringify({ state: "ready" }) } as MessageEvent); - expect(received).toEqual([{ state: "ready" }]); - unsubscribe(); - expect(closed).toBe(true); - }); - - test("falls back when dashboard tile events are unavailable or fail", () => { - let unavailableCount = 0; - - subscribeToDashboardTileEvents({ - createEventSource: undefined, - onTile: () => undefined, - onUnavailable: () => { - unavailableCount += 1; - }, - }); - - let errorHandler: (() => void) | null = null; - const unsubscribe = subscribeToDashboardTileEvents({ - createEventSource: () => ({ - addEventListener: () => undefined, - close: () => undefined, - get onerror() { - return errorHandler; - }, - set onerror(handler) { - errorHandler = handler; - }, - }), - onTile: () => undefined, - onUnavailable: () => { - unavailableCount += 1; - }, - }); - - if (!errorHandler) throw new Error("expected error handler"); - const triggerError = errorHandler as unknown as () => void; - triggerError(); - unsubscribe(); - expect(unavailableCount).toBe(2); - }); - - test("backs off failed tile keys and resets after success", () => { - const backoff = createDashboardTileBackoff(); - - backoff.recordFailure("telemetry:infra-ram", 1_000); - expect(backoff.canAttempt("telemetry:infra-ram", 15_999)).toBe(false); - expect(backoff.canAttempt("telemetry:infra-ram", 16_000)).toBe(true); - - backoff.recordFailure("telemetry:infra-ram", 16_000); - expect(backoff.canAttempt("telemetry:infra-ram", 45_999)).toBe(false); - expect(backoff.canAttempt("telemetry:infra-ram", 46_000)).toBe(true); - - backoff.recordFailure("telemetry:infra-ram", 46_000); - backoff.recordFailure("telemetry:infra-ram", 106_000); - expect(backoff.canAttempt("telemetry:infra-ram", 225_999)).toBe(false); - expect(backoff.canAttempt("telemetry:infra-ram", 226_000)).toBe(true); - - backoff.recordSuccess("telemetry:infra-ram"); - expect(backoff.canAttempt("telemetry:infra-ram", 107_000)).toBe(true); - }); - - test("stores only ready tile snapshots and restores them for matching revisions", () => { - const storage = createMemoryStorage(); - let now = 1_000; - const store = createDashboardTileSnapshotStore(storage, { - now: () => now, - }); - - store.saveReadyTile({ - currentRevisionId: "revision-a", - response: { - state: "ready", - tile: { kind: "telemetry", id: "infra-ram" }, - item: { - id: "infra-ram", - label: "Infra RAM", - value: { kind: "percent", value: 42 }, - detail: "live", - severity: "ok", - }, - }, - schemaVersion: "dashboard.v1", - }); - store.saveTile({ - currentRevisionId: "revision-a", - response: { - state: "not_found", - tile: { kind: "telemetry", id: "missing" }, - message: "Missing", - }, - schemaVersion: "dashboard.v1", - }); - store.saveTile({ - currentRevisionId: "revision-a", - response: { - state: "disabled", - tile: { kind: "module", id: "disabled-module" }, - message: "Disabled", - }, - schemaVersion: "dashboard.v1", - }); - - now = 61_000; - - const restored = store.restore({ - currentRevisionId: "revision-a", - schemaVersion: "dashboard.v1", - }); - - expect(restored).toEqual([ - { - ageMs: 60_000, - response: { - state: "ready", - tile: { kind: "telemetry", id: "infra-ram" }, - item: { - id: "infra-ram", - label: "Infra RAM", - value: { kind: "percent", value: 42 }, - detail: "live - stale 60s", - severity: "stale", - }, - }, - }, - ]); - expect(store.restore({ - currentRevisionId: "revision-b", - schemaVersion: "dashboard.v1", - })).toEqual([]); - }); - - test("ignores malformed stored tile snapshots", () => { - const storage = createMemoryStorage(); - storage.setItem( - "dimensionlab.dashboard.tiles.v1", - JSON.stringify({ - currentRevisionId: "revision-a", - schemaVersion: "dashboard.v1", - tiles: [ - {}, - { - item: { - id: "incomplete", - detail: "live", - }, - savedAt: 1_000, - tile: { kind: "telemetry", id: "incomplete" }, - }, - { - item: { - id: "infra-ram", - label: "Infra RAM", - value: { kind: "percent", value: 42 }, - detail: "live", - severity: "ok", - }, - savedAt: 1_000, - tile: { kind: "telemetry", id: "infra-ram" }, - }, - { - item: { id: "broken-detail", detail: 42 }, - savedAt: 1_000, - tile: { kind: "telemetry", id: "broken-detail" }, - }, - ], - version: 1, - }), - ); - - const store = createDashboardTileSnapshotStore(storage, { - now: () => 16_000, - }); - - expect(store.restore({ - currentRevisionId: "revision-a", - schemaVersion: "dashboard.v1", - })).toEqual([ - { - ageMs: 15_000, - response: { - state: "ready", - tile: { kind: "telemetry", id: "infra-ram" }, - item: { - id: "infra-ram", - label: "Infra RAM", - value: { kind: "percent", value: 42 }, - detail: "live - stale 15s", - severity: "stale", - }, - }, - }, - ]); - }); - - test("ignores restored aggregate health snapshots", () => { - const storage = createMemoryStorage(); - storage.setItem( - "dimensionlab.dashboard.tiles.v1", - JSON.stringify({ - currentRevisionId: "revision-a", - schemaVersion: "dashboard.v1", - tiles: [ - { - item: { - id: "system-status", - label: "System Status", - value: "20 services down", - severity: "danger", - }, - savedAt: 1_000, - tile: { kind: "status", stripId: "footer-status", id: "system-status" }, - }, - { - item: { - id: "runtime-health-summary", - kind: "summary", - title: "Runtime Health", - value: "20 services down", - detail: "0 warnings - 8 services ok", - severity: "danger", - }, - savedAt: 1_000, - tile: { kind: "module", id: "runtime-health-summary" }, - }, - { - item: { - id: "infra-ram", - label: "Infra RAM", - value: { kind: "percent", value: 42 }, - detail: "live", - severity: "ok", - }, - savedAt: 1_000, - tile: { kind: "telemetry", id: "infra-ram" }, - }, - ], - version: 1, - }), - ); - - const store = createDashboardTileSnapshotStore(storage, { - now: () => 16_000, - }); - - expect(store.restore({ - currentRevisionId: "revision-a", - schemaVersion: "dashboard.v1", - })).toEqual([ - { - ageMs: 15_000, - response: { - state: "ready", - tile: { kind: "telemetry", id: "infra-ram" }, - item: { - id: "infra-ram", - label: "Infra RAM", - value: { kind: "percent", value: 42 }, - detail: "live - stale 15s", - severity: "stale", - }, - }, - }, - ]); - }); -}); - -async function waitFor(predicate: () => boolean) { - for (let attempt = 0; attempt < 20; attempt += 1) { - if (predicate()) return; - await Promise.resolve(); - } - - throw new Error("condition was not met"); -} - -function createMemoryStorage(): Storage { - const values = new Map(); - return { - get length() { - return values.size; - }, - clear() { - values.clear(); - }, - getItem(key) { - return values.get(key) ?? null; - }, - key(index) { - return [...values.keys()][index] ?? null; - }, - removeItem(key) { - values.delete(key); - }, - setItem(key, value) { - values.set(key, value); - }, - }; -} - -function modelElement(modelId: string) { - return { - getAttribute(name: string) { - return name === "data-model-id" ? modelId : null; - }, - }; -} diff --git a/apps/web/src/lib/client/dashboard-refresh.ts b/apps/web/src/lib/client/dashboard-refresh.ts deleted file mode 100644 index 7c50359..0000000 --- a/apps/web/src/lib/client/dashboard-refresh.ts +++ /dev/null @@ -1,886 +0,0 @@ -export interface DashboardRefreshPauseState { - visibilityState: DocumentVisibilityState; - online: boolean; -} - -export type DashboardTileReference = - | { kind: "telemetry"; id: string } - | { kind: "service"; groupId: string; id: string } - | { kind: "module"; id: string } - | { kind: "status"; stripId: string; id: string }; - -type SnapshotMetricValue = Record; - -export type DashboardTileSnapshotItem = { - detail?: string; - id: string; - label?: string; - severity?: string; - value?: SnapshotMetricValue | string; -} & Record; - -export type DashboardTileSnapshotResponse = - | { - state: "ready"; - tile: DashboardTileReference; - item: DashboardTileSnapshotItem; - } - | { - state: "not_found"; - tile: DashboardTileReference; - message: string; - } - | { - state: "disabled"; - tile: DashboardTileReference; - message: string; - }; - -export function shouldPauseDashboardRefresh( - state: DashboardRefreshPauseState, -): boolean { - return state.visibilityState !== "visible" || !state.online; -} - -export function createDashboardRequestAborter() { - let shellController: AbortController | undefined; - let tileController: AbortController | undefined; - - function abortController(controller: AbortController | undefined) { - if (controller && !controller.signal.aborted) { - controller.abort(); - } - } - - return { - beginShellRun(): AbortSignal { - abortController(shellController); - abortController(tileController); - shellController = new AbortController(); - tileController = undefined; - return shellController.signal; - }, - beginTileRun(): AbortSignal { - abortController(tileController); - tileController = new AbortController(); - return tileController.signal; - }, - abortActiveRequests(): void { - abortController(shellController); - abortController(tileController); - shellController = undefined; - tileController = undefined; - }, - }; -} - -type DashboardRefreshEventTarget = Pick< - EventTarget, - "addEventListener" | "removeEventListener" ->; - -export interface DashboardRefreshLifecycleOptions { - documentTarget: DashboardRefreshEventTarget; - windowTarget: DashboardRefreshEventTarget; - loadDashboard: () => void | Promise; - pauseRefreshes: () => void; - refreshPaused: () => boolean; -} - -export function attachDashboardRefreshLifecycle( - options: DashboardRefreshLifecycleOptions, -): () => void { - function handleRefreshLifecycleChange() { - if (options.refreshPaused()) { - options.pauseRefreshes(); - return; - } - - void options.loadDashboard(); - } - - function handlePageHide() { - options.pauseRefreshes(); - } - - options.documentTarget.addEventListener( - "visibilitychange", - handleRefreshLifecycleChange, - ); - options.windowTarget.addEventListener("online", handleRefreshLifecycleChange); - options.windowTarget.addEventListener("offline", handleRefreshLifecycleChange); - options.windowTarget.addEventListener("pagehide", handlePageHide); - - return () => { - options.documentTarget.removeEventListener( - "visibilitychange", - handleRefreshLifecycleChange, - ); - options.windowTarget.removeEventListener("online", handleRefreshLifecycleChange); - options.windowTarget.removeEventListener("offline", handleRefreshLifecycleChange); - options.windowTarget.removeEventListener("pagehide", handlePageHide); - }; -} - -export interface DashboardHydrationQueueOptions { - concurrency: number; - hydrate: (item: TItem) => Promise | void; - items: TItem[]; - signal: AbortSignal; -} - -export async function runDashboardHydrationQueue( - options: DashboardHydrationQueueOptions, -): Promise { - const concurrency = Math.max(1, Math.floor(options.concurrency)); - let nextIndex = 0; - - async function worker() { - while (!options.signal.aborted) { - const item = options.items[nextIndex]; - nextIndex += 1; - if (item === undefined) return; - - await options.hydrate(item); - } - } - - const workerCount = Math.min(concurrency, options.items.length); - await Promise.all( - Array.from({ length: workerCount }, () => worker()), - ); -} - -export interface DashboardViewportHydrationQueueOptions { - batchSize?: number; - collectVisibleModelIds: () => Promise>; - concurrency: number; - getModelId: (item: TItem) => string; - hydrate: (item: TItem) => Promise | void; - hydrateBatch?: (items: TItem[]) => Promise | void; - items: TItem[]; - onAllItemsSettled?: () => void; - onVisibleItemsSettled?: () => void; - signal: AbortSignal; - waitForIdle: () => Promise; -} - -export async function runViewportAwareDashboardHydrationQueue( - options: DashboardViewportHydrationQueueOptions, -): Promise { - const visibleModelIds = await options.collectVisibleModelIds(); - if (options.signal.aborted) return; - - const { visible, deferred } = splitDashboardHydrationItemsByVisibility({ - getModelId: options.getModelId, - items: options.items, - visibleModelIds, - }); - - await runDashboardHydrationItems(options, visible); - if (options.signal.aborted) return; - options.onVisibleItemsSettled?.(); - - if (!deferred.length) { - options.onAllItemsSettled?.(); - return; - } - - await options.waitForIdle(); - if (options.signal.aborted) return; - - await runDashboardHydrationItems(options, deferred); - options.onAllItemsSettled?.(); -} - -async function runDashboardHydrationItems( - options: DashboardViewportHydrationQueueOptions, - items: TItem[], -): Promise { - if (!options.hydrateBatch) { - await runDashboardHydrationQueue({ - concurrency: options.concurrency, - hydrate: options.hydrate, - items, - signal: options.signal, - }); - return; - } - - await runDashboardHydrationBatchQueue({ - batchSize: options.batchSize ?? options.concurrency, - hydrateBatch: options.hydrateBatch, - items, - signal: options.signal, - }); -} - -export async function runDashboardHydrationBatchQueue(options: { - batchSize: number; - hydrateBatch: (items: TItem[]) => Promise | void; - items: TItem[]; - signal: AbortSignal; -}): Promise { - const batchSize = Math.max(1, Math.floor(options.batchSize)); - - for (let index = 0; index < options.items.length; index += batchSize) { - if (options.signal.aborted) return; - await options.hydrateBatch(options.items.slice(index, index + batchSize)); - } -} - -export function splitDashboardHydrationItemsByVisibility(options: { - getModelId: (item: TItem) => string; - items: TItem[]; - visibleModelIds: ReadonlySet; -}): { - deferred: TItem[]; - visible: TItem[]; -} { - const visible: TItem[] = []; - const deferred: TItem[] = []; - - for (const item of options.items) { - if (options.visibleModelIds.has(options.getModelId(item))) { - visible.push(item); - } else { - deferred.push(item); - } - } - - return { visible, deferred }; -} - -export interface DashboardModelElement { - getAttribute(name: string): string | null; -} - -export interface DashboardViewportElementSource { - querySelectorAll(selector: string): ArrayLike; -} - -export interface DashboardIntersectionEntry { - intersectionRatio?: number; - isIntersecting: boolean; - target: DashboardModelElement; -} - -export interface DashboardIntersectionObserver { - disconnect(): void; - observe(element: DashboardModelElement): void; -} - -export type DashboardIntersectionObserverFactory = ( - callback: (entries: DashboardIntersectionEntry[]) => void, -) => DashboardIntersectionObserver; - -type DashboardTimerHandle = ReturnType; - -export interface DashboardVisibleModelIdCollectorOptions { - clearTimeout?: (handle: DashboardTimerHandle) => void; - createObserver?: DashboardIntersectionObserverFactory; - documentTarget: DashboardViewportElementSource; - modelIds: Iterable; - setTimeout?: (callback: () => void, timeoutMs: number) => DashboardTimerHandle; - signal: AbortSignal; - timeoutMs?: number; -} - -export async function collectVisibleDashboardModelIds( - options: DashboardVisibleModelIdCollectorOptions, -): Promise> { - const targetIds = new Set(options.modelIds); - if (!targetIds.size || options.signal.aborted) return new Set(); - - const elements = Array.from( - options.documentTarget.querySelectorAll("[data-model-id]"), - ).filter((element) => { - const modelId = element.getAttribute("data-model-id"); - return modelId ? targetIds.has(modelId) : false; - }); - - if (!elements.length) return new Set(); - if (!options.createObserver) return targetIds; - const createObserver = options.createObserver; - - const setTimer = - options.setTimeout || - ((callback: () => void, timeoutMs: number) => - globalThis.setTimeout(callback, timeoutMs)); - const clearTimer = - options.clearTimeout || - ((handle: DashboardTimerHandle) => globalThis.clearTimeout(handle)); - const timeoutMs = options.timeoutMs ?? 80; - - return new Promise((resolve) => { - const visibleModelIds = new Set(); - let settled = false; - let timeoutHandle: DashboardTimerHandle | undefined; - let observer: DashboardIntersectionObserver | undefined; - - function cleanup() { - if (timeoutHandle !== undefined) { - clearTimer(timeoutHandle); - } - observer?.disconnect(); - options.signal.removeEventListener("abort", finish); - } - - function finish() { - if (settled) return; - settled = true; - cleanup(); - resolve(visibleModelIds); - } - - observer = createObserver((entries) => { - for (const entry of entries) { - const modelId = entry.target.getAttribute("data-model-id"); - if ( - modelId && - targetIds.has(modelId) && - (entry.isIntersecting || (entry.intersectionRatio ?? 0) > 0) - ) { - visibleModelIds.add(modelId); - } - } - }); - - for (const element of elements) { - observer.observe(element); - } - - options.signal.addEventListener("abort", finish, { once: true }); - timeoutHandle = setTimer(finish, timeoutMs); - }); -} - -export interface DashboardHydrationIdleOptions { - cancelIdleCallback?: (handle: number) => void; - clearTimeout?: (handle: DashboardTimerHandle) => void; - requestIdleCallback?: ( - callback: () => void, - options?: { timeout?: number }, - ) => number; - setTimeout?: (callback: () => void, timeoutMs: number) => DashboardTimerHandle; - signal: AbortSignal; - timeoutMs?: number; -} - -export async function waitForDashboardHydrationIdle( - options: DashboardHydrationIdleOptions, -): Promise { - if (options.signal.aborted) return; - - const setTimer = - options.setTimeout || - ((callback: () => void, timeoutMs: number) => - globalThis.setTimeout(callback, timeoutMs)); - const clearTimer = - options.clearTimeout || - ((handle: DashboardTimerHandle) => globalThis.clearTimeout(handle)); - - await new Promise((resolve) => { - let settled = false; - let idleHandle: number | undefined; - let timeoutHandle: DashboardTimerHandle | undefined; - - function cleanup() { - if (idleHandle !== undefined) { - options.cancelIdleCallback?.(idleHandle); - } - if (timeoutHandle !== undefined) { - clearTimer(timeoutHandle); - } - options.signal.removeEventListener("abort", finish); - } - - function finish() { - if (settled) return; - settled = true; - cleanup(); - resolve(); - } - - options.signal.addEventListener("abort", finish, { once: true }); - if (options.requestIdleCallback) { - idleHandle = options.requestIdleCallback(finish, { - timeout: options.timeoutMs ?? 1_000, - }); - } else { - timeoutHandle = setTimer(finish, 0); - } - }); -} - -export interface DashboardRefreshDelayOptions { - failureMultiplierLimit?: number; - jitterRatio?: number; - random?: () => number; -} - -export function createDashboardRefreshDelay( - options: DashboardRefreshDelayOptions = {}, -) { - const random = options.random || Math.random; - const jitterRatio = options.jitterRatio ?? 0.1; - const failureMultiplierLimit = options.failureMultiplierLimit ?? 8; - let consecutiveFailures = 0; - - return { - nextDelayMs(baseDelayMs: number): number { - const failureMultiplier = consecutiveFailures - ? Math.min(2 ** consecutiveFailures, failureMultiplierLimit) - : 1; - const jitterFactor = 1 + ((random() * 2) - 1) * jitterRatio; - return Math.max(0, Math.round(baseDelayMs * failureMultiplier * jitterFactor)); - }, - recordFailure(): void { - consecutiveFailures += 1; - }, - recordSuccess(): void { - consecutiveFailures = 0; - }, - }; -} - -export interface DashboardPerformanceMarkOptions { - mark?: (name: string) => void; -} - -export const dashboardPerformanceMarks = { - allTilesSettled: "dashboard:all-tiles-settled", - firstTileReady: "dashboard:first-tile-ready", - shellLoad: "dashboard:shell-load", - visibleTilesReady: "dashboard:visible-tiles-ready", -} as const; - -export function createDashboardPerformanceMarks( - options: DashboardPerformanceMarkOptions = {}, -) { - const mark = options.mark || - globalThis.performance?.mark?.bind(globalThis.performance); - let firstTileReadyMarked = false; - - function safeMark(name: string) { - try { - mark?.(name); - } catch { - // Performance marks are diagnostics only. - } - } - - return { - markAllTilesSettled(): void { - safeMark(dashboardPerformanceMarks.allTilesSettled); - }, - markFirstTileReady(): void { - if (firstTileReadyMarked) return; - firstTileReadyMarked = true; - safeMark(dashboardPerformanceMarks.firstTileReady); - }, - markShellLoad(): void { - firstTileReadyMarked = false; - safeMark(dashboardPerformanceMarks.shellLoad); - }, - markVisibleTilesReady(): void { - safeMark(dashboardPerformanceMarks.visibleTilesReady); - }, - }; -} - -export interface DashboardTileEventSource { - addEventListener( - type: "dashboard-tile", - listener: (event: MessageEvent) => void, - ): void; - close(): void; - onerror: (() => void) | null; -} - -export interface DashboardTileEventSubscriptionOptions { - createEventSource?: (url: string) => DashboardTileEventSource; - onTile: (data: unknown) => void; - onUnavailable?: () => void; - url?: string; -} - -export function subscribeToDashboardTileEvents( - options: DashboardTileEventSubscriptionOptions, -): () => void { - const createEventSource = options.createEventSource || - (typeof globalThis.EventSource !== "undefined" - ? (url: string) => new globalThis.EventSource(url) - : undefined); - - if (!createEventSource) { - options.onUnavailable?.(); - return () => undefined; - } - - const source = createEventSource(options.url || "/api/dashboard/events"); - source.addEventListener("dashboard-tile", (event) => { - try { - options.onTile(JSON.parse(event.data)); - } catch { - // Ignore malformed diagnostics from an optional live transport. - } - }); - source.onerror = () => { - source.close(); - options.onUnavailable?.(); - }; - - return () => source.close(); -} - -const dashboardTileBackoffDelaysMs = [15_000, 30_000, 60_000, 120_000]; - -export function createDashboardTileBackoff() { - const failures = new Map(); - - return { - canAttempt(key: string, now = Date.now()): boolean { - const failure = failures.get(key); - return !failure || now >= failure.nextAttemptAt; - }, - recordFailure(key: string, now = Date.now()): void { - const previousAttempts = failures.get(key)?.attempts || 0; - const attempts = previousAttempts + 1; - const delay = - dashboardTileBackoffDelaysMs[ - Math.min(attempts - 1, dashboardTileBackoffDelaysMs.length - 1) - ]; - failures.set(key, { - attempts, - nextAttemptAt: now + delay, - }); - }, - recordSuccess(key: string): void { - failures.delete(key); - }, - }; -} - -interface DashboardTileSnapshotRecord { - item: DashboardTileSnapshotItem; - savedAt: number; - tile: DashboardTileReference; -} - -interface DashboardTileSnapshotPayload { - currentRevisionId: string; - schemaVersion: string; - tiles: DashboardTileSnapshotRecord[]; - version: 1; -} - -export interface DashboardTileSnapshotStoreContext { - currentRevisionId: string; - schemaVersion: string; -} - -export interface DashboardTileSnapshotStoreOptions { - now?: () => number; -} - -export interface RestoredDashboardTileSnapshot { - ageMs: number; - response: Extract; -} - -const dashboardTileSnapshotStorageKey = "dimensionlab.dashboard.tiles.v1"; - -export function createDashboardTileSnapshotStore( - storage: Storage | undefined, - options: DashboardTileSnapshotStoreOptions = {}, -) { - const now = options.now || Date.now; - - function read(): DashboardTileSnapshotPayload | null { - if (!storage) return null; - - try { - const serialized = storage.getItem(dashboardTileSnapshotStorageKey); - if (!serialized) return null; - - const payload = JSON.parse(serialized) as Partial; - if ( - payload.version !== 1 || - typeof payload.currentRevisionId !== "string" || - typeof payload.schemaVersion !== "string" || - !Array.isArray(payload.tiles) - ) { - return null; - } - - return { - currentRevisionId: payload.currentRevisionId, - schemaVersion: payload.schemaVersion, - tiles: payload.tiles - .filter(isDashboardTileSnapshotRecord) - .filter((record) => isPersistableDashboardTileSnapshot(record.tile)), - version: 1, - }; - } catch { - return null; - } - } - - function write(payload: DashboardTileSnapshotPayload): void { - if (!storage) return; - - try { - storage.setItem(dashboardTileSnapshotStorageKey, JSON.stringify(payload)); - } catch { - // Best-effort warm-start cache; quota and privacy failures are non-fatal. - } - } - - function matchingPayload( - context: DashboardTileSnapshotStoreContext, - ): DashboardTileSnapshotPayload { - const payload = read(); - if ( - payload && - payload.currentRevisionId === context.currentRevisionId && - payload.schemaVersion === context.schemaVersion - ) { - return payload; - } - - return { - currentRevisionId: context.currentRevisionId, - schemaVersion: context.schemaVersion, - tiles: [], - version: 1, - }; - } - - function saveReadyTile( - input: DashboardTileSnapshotStoreContext & { - response: Extract; - }, - ): void { - if (!isPersistableDashboardTileSnapshot(input.response.tile)) return; - - const payload = matchingPayload(input); - const key = dashboardTileSnapshotKey(input.response.tile); - const nextRecord: DashboardTileSnapshotRecord = { - item: input.response.item, - savedAt: now(), - tile: input.response.tile, - }; - payload.tiles = [ - nextRecord, - ...payload.tiles.filter((record) => - dashboardTileSnapshotKey(record.tile) !== key - ), - ]; - write(payload); - } - - return { - restore(context: DashboardTileSnapshotStoreContext): RestoredDashboardTileSnapshot[] { - const payload = read(); - if ( - !payload || - payload.currentRevisionId !== context.currentRevisionId || - payload.schemaVersion !== context.schemaVersion - ) { - return []; - } - - const restoredAt = now(); - return payload.tiles.map((record) => ({ - ageMs: Math.max(0, restoredAt - record.savedAt), - response: { - state: "ready", - tile: record.tile, - item: { - ...record.item, - detail: staleDashboardTileDetail(record.item.detail, restoredAt - record.savedAt), - severity: "stale", - }, - }, - })); - }, - saveReadyTile, - saveTile(input: DashboardTileSnapshotStoreContext & { - response: DashboardTileSnapshotResponse; - }): void { - if (input.response.state === "ready") { - saveReadyTile({ - currentRevisionId: input.currentRevisionId, - response: input.response, - schemaVersion: input.schemaVersion, - }); - } - }, - }; -} - -function dashboardTileSnapshotKey(tile: DashboardTileReference): string { - return JSON.stringify(tile); -} - -export function isPersistableDashboardTileSnapshot( - tile: DashboardTileReference, -): boolean { - if (tile.kind === "status" && tile.id === "system-status") return false; - if (tile.kind === "module" && tile.id === "runtime-health-summary") return false; - return true; -} - -function isDashboardTileSnapshotRecord( - value: unknown, -): value is DashboardTileSnapshotRecord { - if (!isSnapshotRecord(value)) return false; - - return ( - typeof value.savedAt === "number" && - Number.isFinite(value.savedAt) && - isDashboardTileReference(value.tile) && - isDashboardTileSnapshotItem(value.tile, value.item) - ); -} - -function isDashboardTileReference( - value: unknown, -): value is DashboardTileReference { - if (!isSnapshotRecord(value) || typeof value.kind !== "string") return false; - - switch (value.kind) { - case "telemetry": - case "module": - return typeof value.id === "string"; - case "service": - return typeof value.groupId === "string" && typeof value.id === "string"; - case "status": - return typeof value.stripId === "string" && typeof value.id === "string"; - default: - return false; - } -} - -function isDashboardTileSnapshotItem( - tile: DashboardTileReference, - value: unknown, -): value is DashboardTileSnapshotItem { - if (!isSnapshotRecord(value) || value.id !== tile.id) return false; - - switch (tile.kind) { - case "telemetry": - return ( - typeof value.label === "string" && - isMetricValue(value.value) && - isSeverity(value.severity) && - isOptionalString(value.detail) && - isOptionalString(value.description) && - isOptionalString(value.icon) && - isOptionalNumberArray(value.sparkline) - ); - case "service": - return ( - typeof value.label === "string" && - typeof value.description === "string" && - isSeverity(value.severity) && - isOptionalString(value.detail) && - isOptionalString(value.icon) && - isOptionalLink(value.link) - ); - case "module": - return ( - (value.kind === "summary" || - value.kind === "weather" || - value.kind === "custom") && - isOptionalString(value.title) && - isOptionalString(value.label) && - isOptionalString(value.value) && - isOptionalString(value.detail) && - isOptionalString(value.icon) && - (value.severity === undefined || isSeverity(value.severity)) - ); - case "status": - return ( - typeof value.label === "string" && - typeof value.value === "string" && - isOptionalLink(value.link) && - (value.severity === undefined || isSeverity(value.severity)) - ); - } -} - -function isSnapshotRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isMetricValue(value: unknown): boolean { - if (!isSnapshotRecord(value) || typeof value.kind !== "string") return false; - - if (value.kind === "text") { - return ( - typeof value.value === "string" && - isOptionalString(value.unit) - ); - } - - const numericKinds = ["bytes", "latency", "number", "percent", "temperature"]; - if (!numericKinds.includes(value.kind)) return false; - if (typeof value.value !== "number" || !Number.isFinite(value.value)) return false; - if (value.kind === "percent" && (value.value < 0 || value.value > 100)) { - return false; - } - - const precision = value.precision; - return ( - isOptionalString(value.unit) && - (precision === undefined || - (typeof precision === "number" && - Number.isInteger(precision) && - precision >= 0 && - precision <= 4)) - ); -} - -function isSeverity(value: unknown): boolean { - return ( - value === "neutral" || - value === "ok" || - value === "warning" || - value === "danger" || - value === "stale" || - value === "unavailable" - ); -} - -function isOptionalString(value: unknown): boolean { - return value === undefined || typeof value === "string"; -} - -function isOptionalNumberArray(value: unknown): boolean { - return ( - value === undefined || - (Array.isArray(value) && - value.every((item) => typeof item === "number" && Number.isFinite(item))) - ); -} - -function isOptionalLink(value: unknown): boolean { - return ( - value === undefined || - (isSnapshotRecord(value) && - typeof value.href === "string" && - isOptionalString(value.label) && - (value.external === undefined || typeof value.external === "boolean")) - ); -} - -function staleDashboardTileDetail( - detail: string | undefined, - ageMs: number, -): string | undefined { - if (!detail) return detail; - const ageSeconds = Math.max(0, Math.floor(ageMs / 1_000)); - return ageSeconds > 0 ? `${detail} - stale ${ageSeconds}s` : detail; -} diff --git a/apps/web/src/lib/workspace-boundary.test.ts b/apps/web/src/lib/workspace-boundary.test.ts deleted file mode 100644 index 58cf203..0000000 --- a/apps/web/src/lib/workspace-boundary.test.ts +++ /dev/null @@ -1,578 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, test } from "vitest"; - -const root = existsSync(join(process.cwd(), "turbo.json")) - ? process.cwd() - : join(process.cwd(), "..", ".."); - -describe("workspace boundaries", () => { - test("declares the root as a turbo-managed bun workspace", () => { - const packageJson = JSON.parse( - readFileSync(join(root, "package.json"), "utf8"), - ) as { - private?: boolean; - scripts?: Record; - workspaces?: string[]; - }; - - expect(packageJson.private).toBe(true); - expect(packageJson.workspaces).toEqual(["apps/*", "packages/*"]); - expect(packageJson.scripts?.build).toBe("turbo run build"); - expect(existsSync(join(root, "turbo.json"))).toBe(true); - }); - - test("keeps release orchestration in the root turbo task graph", () => { - const rootPackage = JSON.parse( - readFileSync(join(root, "package.json"), "utf8"), - ) as { - scripts?: Record; - }; - const webPackage = JSON.parse( - readFileSync(join(root, "apps/web/package.json"), "utf8"), - ) as { scripts?: Record }; - const turboConfig = JSON.parse( - readFileSync(join(root, "turbo.json"), "utf8"), - ) as { - globalDependencies?: string[]; - tasks?: Record; - }; - - expect(rootPackage.scripts?.["test:qa"]).toBe( - "turbo run check test:unit build @dimensionlab/ui#build-storybook @dimensionlab/web#test:e2e", - ); - expect(webPackage.scripts).not.toHaveProperty("test:qa"); - expect(webPackage.scripts?.test).toBe("bun --bun vitest run"); - expect(webPackage.scripts?.["test:unit"]).toBe("bun --bun vitest run"); - expect(turboConfig.tasks).not.toHaveProperty("test:qa"); - expect(turboConfig.globalDependencies).toEqual( - expect.arrayContaining(["bun.lock", "tsconfig.base.json"]), - ); - expect(turboConfig.tasks?.["test:e2e"]?.env).toEqual( - expect.arrayContaining([ - "CI", - "PLAYWRIGHT_DATABASE_URL", - "PLAYWRIGHT_PORT", - "PLAYWRIGHT_STORYBOOK_PORT", - ]), - ); - }); - - test("lets turbo build app and Storybook artifacts before e2e serves them", () => { - const playwrightConfig = readFileSync( - join(root, "apps/web/playwright.config.ts"), - "utf8", - ); - const turboConfig = JSON.parse( - readFileSync(join(root, "turbo.json"), "utf8"), - ) as { - tasks?: Record; - }; - - expect(playwrightConfig).not.toContain("bun run build &&"); - expect(playwrightConfig).not.toContain("bun run build-storybook"); - expect(turboConfig.tasks?.["test:e2e"]?.dependsOn).toEqual( - expect.arrayContaining(["@dimensionlab/ui#build-storybook"]), - ); - }); - - test("accounts for local env files in cacheable Vite and Storybook task hashes", () => { - const turboConfig = JSON.parse( - readFileSync(join(root, "turbo.json"), "utf8"), - ) as { - tasks?: Record; - }; - - for (const taskName of ["build", "build-storybook"]) { - expect(turboConfig.tasks?.[taskName]?.inputs).toEqual([ - "$TURBO_DEFAULT$", - ".env*", - ]); - } - }); - - test("keeps the website app and reusable UI library as separate packages", () => { - const webPackage = JSON.parse( - readFileSync(join(root, "apps/web/package.json"), "utf8"), - ) as { dependencies?: Record; name?: string }; - const uiPackage = JSON.parse( - readFileSync(join(root, "packages/ui/package.json"), "utf8"), - ) as { exports?: Record; name?: string }; - - expect(webPackage.name).toBe("@dimensionlab/web"); - expect(webPackage.dependencies?.["@dimensionlab/ui"]).toBe("workspace:*"); - expect(uiPackage.name).toBe("@dimensionlab/ui"); - expect(uiPackage.exports).toHaveProperty("."); - expect(uiPackage.exports).toHaveProperty("./styles.css"); - }); - - test("keeps the dashboard model in a reusable internal package", () => { - const webPackage = JSON.parse( - readFileSync(join(root, "apps/web/package.json"), "utf8"), - ) as { dependencies?: Record }; - const modelPackage = JSON.parse( - readFileSync(join(root, "packages/dashboard-model/package.json"), "utf8"), - ) as { exports?: Record; name?: string }; - const tsconfig = JSON.parse( - readFileSync(join(root, "tsconfig.json"), "utf8"), - ) as { references?: Array<{ path: string }> }; - - expect(webPackage.dependencies?.["@dimensionlab/dashboard-model"]).toBe( - "workspace:*", - ); - expect(modelPackage.name).toBe("@dimensionlab/dashboard-model"); - expect(modelPackage.exports).toHaveProperty("."); - expect(modelPackage.exports).toHaveProperty("./fixtures"); - expect(tsconfig.references).toEqual( - expect.arrayContaining([{ path: "./packages/dashboard-model" }]), - ); - expect(existsSync(join(root, "apps/web/src/lib/model/index.ts"))).toBe( - false, - ); - }); - - test("consumes workspace packages through package exports instead of source aliases", () => { - const webTsconfig = JSON.parse( - readFileSync(join(root, "apps/web/tsconfig.json"), "utf8"), - ) as { compilerOptions?: { paths?: Record } }; - const modelPackage = JSON.parse( - readFileSync(join(root, "packages/dashboard-model/package.json"), "utf8"), - ) as { exports?: Record }; - const uiPackage = JSON.parse( - readFileSync(join(root, "packages/ui/package.json"), "utf8"), - ) as { exports?: Record }; - const viteConfig = readFileSync(join(root, "apps/web/vite.config.ts"), "utf8"); - const turboConfig = JSON.parse( - readFileSync(join(root, "turbo.json"), "utf8"), - ) as { tasks?: Record }; - - expect(webTsconfig.compilerOptions?.paths).not.toHaveProperty( - "@dimensionlab/dashboard-model", - ); - expect(webTsconfig.compilerOptions?.paths).not.toHaveProperty( - "@dimensionlab/dashboard-model/fixtures", - ); - expect(webTsconfig.compilerOptions?.paths).not.toHaveProperty("@dimensionlab/ui"); - expect(webTsconfig.compilerOptions?.paths).not.toHaveProperty( - "@dimensionlab/ui/styles.css", - ); - expect(webTsconfig.compilerOptions?.paths).toEqual({ - "$lib/*": ["src/lib/*"], - }); - expect(viteConfig).not.toContain("../../packages/dashboard-model/src"); - expect(viteConfig).not.toContain("../../packages/ui/src"); - expect(turboConfig.tasks?.dev?.dependsOn).toEqual(["^build"]); - expectPackageExport(modelPackage.exports?.["."], { - types: "./dist/index.d.ts", - development: "./src/index.ts", - default: "./dist/index.js", - }); - expectPackageExport(modelPackage.exports?.["./fixtures"], { - types: "./dist/fixtures/index.d.ts", - development: "./src/fixtures/index.ts", - default: "./dist/fixtures/index.js", - }); - expectPackageExport(uiPackage.exports?.["."], { - types: "./dist/index.d.ts", - development: "./src/index.ts", - default: "./dist/index.js", - }); - expectPackageExport(uiPackage.exports?.["./styles.css"], { - development: "./src/styles.css", - default: "./dist/styles.css", - }); - expectPackageExport(uiPackage.exports?.["./tokens.css"], { - development: "./src/tokens.css", - default: "./dist/tokens.css", - }); - }); - - test("builds the internal container from a turbo-pruned web workspace", () => { - const containerfile = readFileSync( - join(root, "apps/web/Containerfile"), - "utf8", - ); - const pruneCommand = "turbo prune @dimensionlab/web --docker"; - const jsonCopy = "COPY --from=pruner /repo/out/json/ ./"; - const sourceCopy = "COPY --from=pruner /repo/out/full/ ./"; - const tsconfigCopy = - "COPY --from=pruner /repo/tsconfig.base.json /repo/tsconfig.json ./"; - const installCommand = "RUN bun install --frozen-lockfile --ignore-scripts"; - - expect(containerfile).toContain("AS pruner"); - expect(containerfile).toContain(pruneCommand); - expect(containerfile).toContain(jsonCopy); - expect(containerfile).toContain(sourceCopy); - expect(containerfile).toContain(tsconfigCopy); - expect(containerfile).not.toContain( - "COPY packages/dashboard-model/package.json", - ); - expect(containerfile).not.toContain("COPY packages/ui/package.json"); - - const jsonCopyIndex = containerfile.indexOf(jsonCopy); - const installIndex = containerfile.indexOf(installCommand); - const sourceCopyIndex = containerfile.indexOf(sourceCopy); - const tsconfigCopyIndex = containerfile.indexOf(tsconfigCopy); - - expect(jsonCopyIndex).toBeGreaterThan(-1); - expect(installIndex).toBeGreaterThan(jsonCopyIndex); - expect(sourceCopyIndex).toBeGreaterThan(installIndex); - expect(tsconfigCopyIndex).toBeGreaterThan(sourceCopyIndex); - expect(containerfile.indexOf("RUN bun run build")).toBeGreaterThan( - tsconfigCopyIndex, - ); - }); - - test("keeps local turbo prune output out of git and container contexts", () => { - const webPackage = JSON.parse( - readFileSync(join(root, "apps/web/package.json"), "utf8"), - ) as { scripts?: Record }; - const gitignore = readFileSync(join(root, ".gitignore"), "utf8"); - const containerignore = readFileSync(join(root, ".containerignore"), "utf8"); - const containerIgnoreRules = new Set( - containerignore - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean), - ); - - expect(gitignore).toContain("out/"); - expect(containerignore).toContain("out"); - expect([...containerIgnoreRules]).toEqual( - expect.arrayContaining([ - "apps/*/.turbo", - "apps/*/build", - "apps/*/data", - "apps/*/dist", - "apps/*/playwright-report", - "apps/*/test-results", - "packages/*/.turbo", - "packages/*/dist", - "packages/*/storybook-static", - ]), - ); - expect(webPackage.scripts?.build).toBe( - "rm -rf build && vite build && bun build src/server/index.ts --target bun --outdir build", - ); - }); - - test("defines Forgejo CI and main-branch deploy automation", () => { - const workflow = readFileSync( - join(root, ".forgejo/workflows/dimensionlab-website.yml"), - "utf8", - ); - - expect(workflow).toContain("name: Dimension Lab website"); - expect(workflow).toContain("pull_request:"); - expect(workflow).toContain("push:"); - expect(workflow).toContain("branches:"); - expect(workflow).toContain("- main"); - expect(workflow).toContain("runs-on: docker"); - expect(workflow).toContain("bun install --frozen-lockfile"); - expect(workflow).toContain("bun run check"); - expect(workflow).toContain("bun run test"); - expect(workflow).toContain("bun run build"); - expect(workflow).toContain("needs: ci"); - expect(workflow).toContain("runs-on: deploy"); - expect(workflow).toContain("github.event_name == 'push'"); - expect(workflow).toContain("github.ref == 'refs/heads/main'"); - expect(workflow).toContain( - "git remote add origin git@git.dimensionlab.net:vince/dimensionlab-website.git", - ); - expect(workflow).toContain('git fetch --force --prune --depth=1 origin "$GITHUB_SHA"'); - expect(workflow).toContain("podman inspect dimensionlab-website"); - expect(workflow).toContain("DEPLOY_CONTAINER_CLI: podman"); - expect(workflow).toContain("PODMAN_SYSTEMD_UNIT"); - expect(workflow).toContain("scripts/deploy-dimensionlab-website.sh"); - }); - - test("keeps production deployment behind a guarded script", () => { - const deployScript = readFileSync( - join(root, "scripts/deploy-dimensionlab-website.sh"), - "utf8", - ); - - expect(deployScript).toContain("refs/heads/main"); - expect(deployScript).toContain("dimensionlab-website.service"); - expect(deployScript).toContain("localhost/dimensionlab-website"); - expect(deployScript).toContain("apps/web/Containerfile"); - expect(deployScript).toContain("rollback-"); - expect(deployScript).toContain("https://dimensionlab.net"); - expect(deployScript).toContain("/api/dashboard/tiles"); - expect(deployScript).toContain("--dry-run"); - expect(deployScript).toContain("DEPLOY_RESTART_STRATEGY"); - }); - - test("rolls back the latest image when production smoke checks fail", () => { - const result = runDeployScriptWithFakes( - { - curl: failingCurlCommand, - git: fakeGitCommand, - podman: fakePodmanCommand("dimensionlab-website.service"), - }, - { - DEPLOY_CONTAINER_CLI: "podman", - DEPLOY_EVENT_NAME: "push", - DEPLOY_REF: "refs/heads/main", - DEPLOY_RESTART_STRATEGY: "quadlet-container", - DEPLOY_SHA: "1234567890abcdef", - DEPLOY_SMOKE_TIMEOUT_SECONDS: "0", - }, - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("smoke checks failed"); - expect(result.stderr).toContain("rolling back to localhost/dimensionlab-website:rollback-"); - expect(result.log).toMatch( - /tag localhost\/dimensionlab-website:latest localhost\/dimensionlab-website:rollback-\d{14}/, - ); - expect(result.log).toMatch( - /tag localhost\/dimensionlab-website:rollback-\d{14} localhost\/dimensionlab-website:latest/, - ); - expect(result.log.match(/^stop dimensionlab-website$/gm)).toHaveLength(2); - }); - - test("rolls back the latest image when the container fails to restart", () => { - const result = runDeployScriptWithFakes( - { - curl: passingCurlCommand, - git: fakeGitCommand, - podman: fakePodmanCommand("dimensionlab-website.service"), - }, - { - DEPLOY_CONTAINER_CLI: "podman", - DEPLOY_CONTAINER_START_TIMEOUT_SECONDS: "1", - DEPLOY_TEST_CONTAINER_IMAGE: "wrong", - DEPLOY_EVENT_NAME: "push", - DEPLOY_REF: "refs/heads/main", - DEPLOY_RESTART_STRATEGY: "quadlet-container", - DEPLOY_SHA: "1234567890abcdef", - }, - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("did not restart on localhost/dimensionlab-website:latest"); - expect(result.stderr).toContain("rolling back to localhost/dimensionlab-website:rollback-"); - expect(result.stderr).toContain("rollback image is running"); - expect(result.log).toMatch( - /tag localhost\/dimensionlab-website:rollback-\d{14} localhost\/dimensionlab-website:latest/, - ); - expect(result.log.match(/^stop dimensionlab-website$/gm)).toHaveLength(2); - }); - - test.each([ - { - env: { DEPLOY_EVENT_NAME: "pull_request", DEPLOY_REF: "refs/heads/main" }, - message: "production deploys only run for push", - }, - { - env: { DEPLOY_EVENT_NAME: "push", DEPLOY_REF: "refs/heads/codex/test" }, - message: "expected refs/heads/main", - }, - ])("refuses guarded deploy contexts before host mutations", ({ env, message }) => { - const result = runDeployScriptWithFakes( - { - curl: passingCurlCommand, - git: fakeGitCommand, - podman: fakePodmanCommand("dimensionlab-website.service"), - }, - { - DEPLOY_CONTAINER_CLI: "podman", - DEPLOY_SHA: "1234567890abcdef", - ...env, - }, - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain(message); - expect(result.log).toBe(""); - }); - - test("refuses stop-based deploys unless the container belongs to the expected unit", () => { - const result = runDeployScriptWithFakes( - { - curl: passingCurlCommand, - git: fakeGitCommand, - podman: fakePodmanCommand("other.service"), - }, - { - DEPLOY_CONTAINER_CLI: "podman", - DEPLOY_EVENT_NAME: "push", - DEPLOY_REF: "refs/heads/main", - DEPLOY_RESTART_STRATEGY: "quadlet-container", - DEPLOY_SHA: "1234567890abcdef", - }, - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain( - "refusing to stop dimensionlab-website; expected PODMAN_SYSTEMD_UNIT=dimensionlab-website.service", - ); - expect(result.log).not.toContain("build "); - expect(result.log).not.toContain("stop dimensionlab-website"); - }); - - test("checks the expected unit before auto falls back to stopping the container", () => { - const result = runDeployScriptWithFakes( - { - curl: passingCurlCommand, - git: fakeGitCommand, - podman: fakePodmanCommand("other.service"), - systemctl: fakeSystemctlCommand({ active: false, show: true }), - }, - { - DEPLOY_CONTAINER_CLI: "podman", - DEPLOY_EVENT_NAME: "push", - DEPLOY_REF: "refs/heads/main", - DEPLOY_RESTART_STRATEGY: "auto", - DEPLOY_SHA: "1234567890abcdef", - }, - ); - - expect(result.status).toBe(1); - expect(result.stderr).toContain( - "refusing to stop dimensionlab-website; expected PODMAN_SYSTEMD_UNIT=dimensionlab-website.service", - ); - expect(result.log).not.toContain("stop dimensionlab-website"); - }); -}); - -type WorkspacePackageExport = - | string - | { - types?: string; - development?: string; - default?: string; - }; - -function expectPackageExport( - actual: WorkspacePackageExport | undefined, - expected: Exclude, -): void { - expect(actual).toMatchObject(expected); -} - -function runDeployScriptWithFakes( - commands: Record, - env: Record, -): { log: string; status: number | null; stderr: string; stdout: string } { - const tempDir = mkdtempSync(join(tmpdir(), "dimensionlab-deploy-test-")); - const logPath = join(tempDir, "commands.log"); - - for (const [name, source] of Object.entries(commands)) { - const commandPath = join(tempDir, name); - writeFileSync(commandPath, source); - chmodSync(commandPath, 0o755); - } - - const result = spawnSync("bash", [join(root, "scripts/deploy-dimensionlab-website.sh")], { - cwd: root, - encoding: "utf8", - env: { - ...process.env, - ...env, - DEPLOY_TEST_LOG: logPath, - PATH: `${tempDir}:${process.env.PATH ?? ""}`, - }, - }); - - return { - log: existsSync(logPath) ? readFileSync(logPath, "utf8") : "", - status: result.status, - stderr: result.stderr, - stdout: result.stdout, - }; -} - -const fakeGitCommand = `#!/usr/bin/env bash -case "$1" in - branch) - echo main - ;; - rev-parse) - echo 1234567890abcdef - ;; - config|submodule) - exit 0 - ;; -esac -`; - -function fakePodmanCommand(systemdUnit: string): string { - return `#!/usr/bin/env bash -state_file="$DEPLOY_TEST_LOG.state" -[ -f "$state_file" ] || printf 'initial' > "$state_file" -printf '%s\\n' "$*" >> "$DEPLOY_TEST_LOG" -if [ "$1" = "image" ] && [ "$2" = "inspect" ]; then - if [ "$4" = "--format" ]; then - case "$3" in - *:rollback-*) - echo sha256:old - ;; - *) - if [ "$(cat "$state_file")" = "rollback" ]; then - echo sha256:old - else - echo sha256:new - fi - ;; - esac - fi - exit 0 -fi -if [ "$1" = "tag" ] && [ "$2" != "localhost/dimensionlab-website:latest" ]; then - printf 'rollback' > "$state_file" -fi -if [ "$1" = "inspect" ]; then - case "$*" in - *PODMAN_SYSTEMD_UNIT*) - echo ${systemdUnit} - ;; - *State.Running*) - echo true - ;; - *'.Image'*|*'{{.Image}}'*) - if [ "$(cat "$state_file")" = "rollback" ]; then - echo sha256:old - elif [ "\${DEPLOY_TEST_CONTAINER_IMAGE:-new}" = "wrong" ]; then - echo sha256:wrong - else - echo sha256:new - fi - ;; - esac -fi -`; -} - -const failingCurlCommand = `#!/usr/bin/env bash -printf 'curl %s\\n' "$*" >> "$DEPLOY_TEST_LOG" -exit 22 -`; - -const passingCurlCommand = `#!/usr/bin/env bash -printf 'curl %s\\n' "$*" >> "$DEPLOY_TEST_LOG" -if [ "$*" = *'/api/dashboard/tiles'* ]; then - printf '{"state":"ready","tiles":[]}' -fi -`; - -function fakeSystemctlCommand(options: { active: boolean; show: boolean }): string { - const activeStatus = options.active ? 0 : 3; - const showStatus = options.show ? 0 : 1; - - return `#!/usr/bin/env bash -printf 'systemctl %s\\n' "$*" >> "$DEPLOY_TEST_LOG" -if [ "$1" = "--user" ] && [ "$2" = "is-active" ]; then - exit ${activeStatus} -fi -if [ "$1" = "--user" ] && [ "$2" = "show" ]; then - exit ${showStatus} -fi -if [ "$1" = "--user" ] && [ "$2" = "restart" ]; then - exit 0 -fi -`; -} diff --git a/apps/web/src/server/index.test.ts b/apps/web/src/server/index.test.ts deleted file mode 100644 index af1035d..0000000 --- a/apps/web/src/server/index.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { afterAll, describe, expect, test, vi } from "vitest"; -import { handleRequest } from "./index"; - -describe("server request routing", () => { - const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined); - - afterAll(() => { - consoleInfo.mockRestore(); - }); - - test("routes dashboard tile batch requests", async () => { - const response = await handleRequest( - new Request("https://example.test/api/dashboard/tiles", { - method: "POST", - body: JSON.stringify({ - tiles: [ - { - kind: "status", - stripId: "footer-status", - id: "auto-refresh", - }, - ], - }), - }), - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - state: "ready", - tiles: [ - { - state: "ready", - tile: { - kind: "status", - stripId: "footer-status", - id: "auto-refresh", - }, - }, - ], - }); - }); - - test("rejects non-post dashboard tile batch requests", async () => { - const response = await handleRequest( - new Request("https://example.test/api/dashboard/tiles", { - method: "GET", - }), - ); - - expect(response.status).toBe(405); - expect(response.headers.get("allow")).toBe("POST"); - }); - - test("routes dashboard event stream requests", async () => { - const response = await handleRequest( - new Request("https://example.test/api/dashboard/events", { - method: "GET", - }), - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "text/event-stream; charset=utf-8", - ); - }); - - test("rejects non-get dashboard event stream requests", async () => { - const response = await handleRequest( - new Request("https://example.test/api/dashboard/events", { - method: "POST", - }), - ); - - expect(response.status).toBe(405); - expect(response.headers.get("allow")).toBe("GET"); - }); -}); diff --git a/apps/web/src/server/routes/dashboard.test.ts b/apps/web/src/server/routes/dashboard.test.ts deleted file mode 100644 index a4cf7ef..0000000 --- a/apps/web/src/server/routes/dashboard.test.ts +++ /dev/null @@ -1,799 +0,0 @@ -import { afterAll, afterEach, describe, expect, test, vi } from "vitest"; -import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; -import { - createDashboardTileCache, - dashboardTileCacheKey, - handleDashboardEventsRoute, - handleDashboardTilesRoute, - handleDashboardTileRoute, - loadDashboardResponse, - loadDashboardTilesResponse, - loadDashboardTileResponse, - type DashboardTileResolutionLogEvent, -} from "./dashboard"; - -describe("dashboard API route", () => { - const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined); - - afterEach(() => { - consoleInfo.mockClear(); - }); - - afterAll(() => { - consoleInfo.mockRestore(); - }); - - test("returns the ready dashboard shell without hydrating live datasources", async () => { - const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue( - new Error("live datasource fetch should not run for the shell response"), - ); - - const response = await loadDashboardResponse({ - refreshSeedDocument: true, - seedIfEmpty: true, - }); - - expect(response.state).toBe("ready"); - if (response.state !== "ready") throw new Error("expected ready dashboard"); - expect(response.document.metadata.title).toBe( - dimensionLabDashboardFixture.metadata.title, - ); - expect(fetch).not.toHaveBeenCalled(); - - fetch.mockRestore(); - }); - - test("reports when client-side live hydration is disabled", async () => { - const previous = process.env.DISABLE_LIVE_DATASOURCES; - process.env.DISABLE_LIVE_DATASOURCES = "1"; - - try { - const response = await loadDashboardResponse({ - refreshSeedDocument: true, - seedIfEmpty: true, - }); - - expect(response.state).toBe("ready"); - if (response.state !== "ready") throw new Error("expected ready dashboard"); - expect(response.liveDatasourceHydration).toEqual({ enabled: false }); - } finally { - if (previous === undefined) { - delete process.env.DISABLE_LIVE_DATASOURCES; - } else { - process.env.DISABLE_LIVE_DATASOURCES = previous; - } - } - }); - - test("hydrates a telemetry tile independently from the dashboard shell", async () => { - const fetch = vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - - if (url.startsWith("https://prometheus.example/api/v1/query_range")) { - return jsonResponse({ - status: "success", - data: { - result: [ - { - metric: { host: "linux-infra" }, - values: [ - [1771430000, "10"], - [1771430060, "20"], - [1771430120, "42"], - ], - }, - ], - }, - }); - } - - if (url.startsWith("https://prometheus.example/api/v1/query")) { - return jsonResponse({ - status: "success", - data: { - result: [ - { - metric: { host: "linux-infra" }, - value: [1771430400, "42"], - }, - ], - }, - }); - } - - throw new Error(`Unhandled test request: ${url}`); - }); - - const response = await loadDashboardTileResponse( - { kind: "telemetry", id: "infra-ram" }, - { - fetch, - prometheusBaseUrl: "https://prometheus.example", - refreshSeedDocument: true, - seedIfEmpty: true, - }, - ); - - expect(response.state).toBe("ready"); - if (response.state !== "ready") throw new Error("expected ready tile"); - expect(response.tile).toEqual({ kind: "telemetry", id: "infra-ram" }); - expect(response.item).toMatchObject({ - id: "infra-ram", - value: { kind: "percent", value: 42 }, - severity: "ok", - detail: "linux-infra", - sparkline: [10, 20, 42], - }); - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/query?"), - expect.objectContaining({ cache: "no-store" }), - ); - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/query_range?"), - expect.objectContaining({ cache: "no-store" }), - ); - }); - - test("hydrates a service tile with its service group identity", async () => { - const fetch = vi.fn(async () => - jsonResponse({ - status: "UP", - ping: 42, - }), - ); - - const response = await loadDashboardTileResponse( - { kind: "service", groupId: "essentials", id: "vaultwarden" }, - { - fetch, - refreshSeedDocument: true, - seedIfEmpty: true, - }, - ); - - expect(response.state).toBe("ready"); - if (response.state !== "ready") throw new Error("expected ready tile"); - expect(response.tile).toEqual({ - kind: "service", - groupId: "essentials", - id: "vaultwarden", - }); - expect(response.item).toMatchObject({ - id: "vaultwarden", - severity: "ok", - detail: "42 ms", - }); - }); - - test("caches ready tile responses until the tile ttl expires", async () => { - const cache = createDashboardTileCache(); - let now = 1_000; - const fetch = vi.fn(async () => { - now += 7; - return jsonResponse({ - status: "UP", - ping: 42, - }); - }); - const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const; - - const first = await loadDashboardTileResponse(tile, { - fetch, - now: () => now, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - const second = await loadDashboardTileResponse(tile, { - fetch, - now: () => now + 29_999, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - now += 30_001; - const third = await loadDashboardTileResponse(tile, { - fetch, - now: () => now, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - - expect(first).toEqual(second); - expect(third.state).toBe("ready"); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - test("logs tile duration and cache hit or miss metadata", async () => { - let now = 1_000; - const logs: DashboardTileResolutionLogEvent[] = []; - const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const; - const service = dimensionLabDashboardFixture.serviceGroups - .flatMap((group) => group.services) - .find((item) => item.id === tile.id); - if (!service) throw new Error("missing service fixture"); - - let cacheCalls = 0; - const tileCache = { - async resolve() { - cacheCalls += 1; - if (cacheCalls === 1) now += 7; - const cacheState = cacheCalls === 1 ? "miss" as const : "hit" as const; - - return { - cache: cacheState, - coalesced: false, - response: { - state: "ready" as const, - tile, - item: service, - }, - }; - }, - }; - - await loadDashboardTileResponse(tile, { - logTileResolution: (event) => logs.push(event), - now: () => now, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache, - }); - now += 10; - await loadDashboardTileResponse(tile, { - logTileResolution: (event) => logs.push(event), - now: () => now, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache, - }); - - expect(logs).toEqual([ - expect.objectContaining({ - cache: "miss", - coalesced: false, - durationMs: 7, - status: "ready", - tileKey: dashboardTileCacheKey(tile), - }), - expect.objectContaining({ - cache: "hit", - coalesced: false, - durationMs: 0, - status: "ready", - tileKey: dashboardTileCacheKey(tile), - }), - ]); - }); - - test("keeps telemetry tiles cached for fifteen seconds", async () => { - const cache = createDashboardTileCache(); - let now = 1_000; - const fetch = telemetryFetch(); - const tile = { kind: "telemetry", id: "infra-ram" } as const; - - await loadDashboardTileResponse(tile, { - fetch, - now: () => now, - prometheusBaseUrl: "https://prometheus.example", - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - await loadDashboardTileResponse(tile, { - fetch, - now: () => now + 14_999, - prometheusBaseUrl: "https://prometheus.example", - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - now += 15_001; - await loadDashboardTileResponse(tile, { - fetch, - now: () => now, - prometheusBaseUrl: "https://prometheus.example", - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - - expect(fetch).toHaveBeenCalledTimes(4); - }); - - test("keeps weather module tiles cached for ten minutes", async () => { - const cache = createDashboardTileCache(); - let now = 1_000; - const fetch = vi.fn(async () => - jsonResponse({ - current: { - apparent_temperature: 19, - temperature_2m: 20, - weather_code: 0, - wind_speed_10m: 11, - }, - }), - ); - const tile = { kind: "module", id: "weather-amsterdam" } as const; - - await loadDashboardTileResponse(tile, { - fetch, - now: () => now, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - await loadDashboardTileResponse(tile, { - fetch, - now: () => now + 599_999, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - now += 600_001; - await loadDashboardTileResponse(tile, { - fetch, - now: () => now, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - - expect(fetch).toHaveBeenCalledTimes(2); - }); - - test("coalesces concurrent tile requests for the same cache key", async () => { - const cache = createDashboardTileCache(); - const logs: DashboardTileResolutionLogEvent[] = []; - let resolveFetch: ((response: Response) => void) | undefined; - const fetch = vi.fn(() => - new Promise((resolve) => { - resolveFetch = resolve; - }) - ); - const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const; - - const first = loadDashboardTileResponse(tile, { - fetch, - logTileResolution: (event) => logs.push(event), - now: () => 1_000, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - const second = loadDashboardTileResponse(tile, { - fetch, - logTileResolution: (event) => logs.push(event), - now: () => 1_000, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }); - - await Promise.resolve(); - expect(fetch).toHaveBeenCalledTimes(1); - - resolveFetch?.(jsonResponse({ status: "UP", ping: 42 })); - expect(await first).toEqual(await second); - expect(logs).toEqual([ - expect.objectContaining({ - cache: "miss", - coalesced: false, - status: "ready", - tileKey: dashboardTileCacheKey(tile), - }), - expect.objectContaining({ - cache: "miss", - coalesced: true, - status: "ready", - tileKey: dashboardTileCacheKey(tile), - }), - ]); - }); - - test("logs coalesced metadata when shared tile requests fail", async () => { - const cache = createDashboardTileCache(); - let rejectLoad: ((error: Error) => void) | undefined; - const load = vi.fn(() => - new Promise((_resolve, reject) => { - rejectLoad = reject; - }) - ); - - const first = cache.resolve("tile-a", 30_000, 1_000, load); - const second = cache.resolve("tile-a", 30_000, 1_000, load); - - await Promise.resolve(); - expect(load).toHaveBeenCalledTimes(1); - - rejectLoad?.(new TypeError("upstream failed")); - await expect(first).rejects.toThrow("upstream failed"); - await expect(second).rejects.toMatchObject({ - cache: "miss", - coalesced: true, - cause: expect.any(TypeError), - }); - }); - - test("logs coalesced metadata for failed tile cache resolution", async () => { - const logs: DashboardTileResolutionLogEvent[] = []; - const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const; - - await expect( - loadDashboardTileResponse(tile, { - logTileResolution: (event) => logs.push(event), - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: { - async resolve() { - throw { - cache: "miss", - cause: new TypeError("coalesced cache failed"), - coalesced: true, - }; - }, - }, - }), - ).rejects.toThrow("coalesced cache failed"); - - expect(logs).toEqual([ - expect.objectContaining({ - cache: "miss", - coalesced: true, - errorCategory: "TypeError", - status: "error", - tileKey: dashboardTileCacheKey(tile), - }), - ]); - }); - - test("logs error categories for failed tile cache resolution", async () => { - const logs: DashboardTileResolutionLogEvent[] = []; - const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const; - - await expect( - loadDashboardTileResponse(tile, { - logTileResolution: (event) => logs.push(event), - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: { - async resolve() { - throw new TypeError("cache failed"); - }, - }, - }), - ).rejects.toThrow("cache failed"); - - expect(logs).toEqual([ - expect.objectContaining({ - cache: "miss", - coalesced: false, - errorCategory: "TypeError", - status: "error", - tileKey: dashboardTileCacheKey(tile), - }), - ]); - }); - - test("uses structured tile cache keys when identifiers contain delimiters", () => { - expect( - dashboardTileCacheKey({ kind: "service", groupId: "a:b", id: "c" }), - ).not.toBe( - dashboardTileCacheKey({ kind: "service", groupId: "a", id: "b:c" }), - ); - expect( - dashboardTileCacheKey({ kind: "status", stripId: "a:b", id: "c" }), - ).not.toBe( - dashboardTileCacheKey({ kind: "status", stripId: "a", id: "b:c" }), - ); - }); - - test("uses thirty-second status aggregate and five-minute static status ttl buckets", async () => { - const cache = createDashboardTileCache(); - const fetch = vi.fn(async () => jsonResponse({ status: "UP", ping: 42 })); - const serviceCheckCount = dimensionLabDashboardFixture.serviceGroups - .flatMap((group) => group.services).length; - - await loadDashboardTileResponse( - { kind: "status", stripId: "footer-status", id: "system-status" }, - { - fetch, - now: () => 1_000, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }, - ); - await loadDashboardTileResponse( - { kind: "status", stripId: "footer-status", id: "system-status" }, - { - fetch, - now: () => 31_001, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }, - ); - - await loadDashboardTileResponse( - { kind: "status", stripId: "footer-status", id: "auto-refresh" }, - { - fetch, - now: () => 1_000, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }, - ); - await loadDashboardTileResponse( - { kind: "status", stripId: "footer-status", id: "auto-refresh" }, - { - fetch, - now: () => 300_999, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }, - ); - await loadDashboardTileResponse( - { kind: "status", stripId: "footer-status", id: "auto-refresh" }, - { - fetch, - now: () => 301_001, - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: cache, - }, - ); - - expect(fetch).toHaveBeenCalledTimes(serviceCheckCount * 2); - }); - - test("serves tile route responses with short private cache headers", async () => { - const response = await handleDashboardTileRoute( - "/api/dashboard/tile/service/essentials/vaultwarden", - { - fetch: vi.fn(async () => jsonResponse({ status: "UP", ping: 42 })), - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: createDashboardTileCache(), - }, - ); - - expect(response.headers.get("cache-control")).toBe( - "private, max-age=5, stale-while-revalidate=30", - ); - }); - - test("serves batch tile route responses", async () => { - const response = await handleDashboardTilesRoute( - new Request("https://example.test/api/dashboard/tiles", { - method: "POST", - body: JSON.stringify({ - tiles: [ - { - kind: "status", - stripId: "footer-status", - id: "auto-refresh", - }, - ], - }), - }), - { - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: createDashboardTileCache(), - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("cache-control")).toBe( - "private, max-age=5, stale-while-revalidate=30", - ); - await expect(response.json()).resolves.toMatchObject({ - state: "ready", - tiles: [ - { - state: "ready", - tile: { - kind: "status", - stripId: "footer-status", - id: "auto-refresh", - }, - }, - ], - }); - }); - - test("streams ready dashboard tile events", async () => { - const controller = new AbortController(); - const response = await handleDashboardEventsRoute({ - refreshSeedDocument: true, - seedIfEmpty: true, - signal: controller.signal, - tileCache: { - async resolve(key) { - controller.abort(); - const tile = JSON.parse(key); - - return { - cache: "miss", - coalesced: false, - response: { - state: "ready", - tile, - item: { - id: tile.id, - label: "Status", - severity: "ok", - value: "ok", - }, - }, - }; - }, - }, - }); - - expect(response.headers.get("content-type")).toBe( - "text/event-stream; charset=utf-8", - ); - expect(response.headers.get("cache-control")).toBe("no-cache"); - const body = await response.text(); - expect(body).toContain("event: dashboard-tile"); - expect(body).toContain('"state":"ready"'); - expect(body).toContain('"tile"'); - }); - - test("rejects invalid batch tile requests", async () => { - const response = await handleDashboardTilesRoute( - new Request("https://example.test/api/dashboard/tiles", { - method: "POST", - body: JSON.stringify({ - tiles: [{ kind: "service", id: "missing-group" }], - }), - }), - ); - - expect(response.status).toBe(400); - }); - - test("resolves batch tile responses with server concurrency capped at six", async () => { - let active = 0; - let maxActive = 0; - const started: string[] = []; - const releases = new Map void>(); - const service = dimensionLabDashboardFixture.serviceGroups - .flatMap((group) => group.services)[0]; - if (!service) throw new Error("missing service fixture"); - const tiles = Array.from({ length: 7 }, (_, index) => ({ - kind: "service" as const, - groupId: "essentials", - id: `service-${index}`, - })); - - const batch = loadDashboardTilesResponse(tiles, { - refreshSeedDocument: true, - seedIfEmpty: true, - tileCache: { - async resolve(key) { - active += 1; - maxActive = Math.max(maxActive, active); - started.push(key); - await new Promise((resolve) => releases.set(key, resolve)); - active -= 1; - - return { - cache: "miss", - coalesced: false, - response: { - state: "ready", - tile: JSON.parse(key), - item: service, - }, - }; - }, - }, - }); - - await waitFor(() => started.length === 6); - expect(maxActive).toBe(6); - releases.get(started[0])?.(); - await waitFor(() => started.length === 7); - for (const release of releases.values()) release(); - - await expect(batch).resolves.toMatchObject({ - state: "ready", - tiles: expect.arrayContaining([ - expect.objectContaining({ - state: "ready", - tile: tiles[0], - }), - ]), - }); - expect(maxActive).toBe(6); - }); - - test("does not hydrate tile routes when live datasources are disabled", async () => { - const previous = process.env.DISABLE_LIVE_DATASOURCES; - process.env.DISABLE_LIVE_DATASOURCES = "1"; - const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue( - new Error("live datasource fetch should not run when disabled"), - ); - - try { - const response = await loadDashboardTileResponse( - { kind: "telemetry", id: "infra-ram" }, - { - refreshSeedDocument: true, - seedIfEmpty: true, - }, - ); - - expect(response.state).toBe("disabled"); - expect(fetch).not.toHaveBeenCalled(); - } finally { - fetch.mockRestore(); - if (previous === undefined) { - delete process.env.DISABLE_LIVE_DATASOURCES; - } else { - process.env.DISABLE_LIVE_DATASOURCES = previous; - } - } - }); -}); - -function jsonResponse(payload: unknown): Response { - return new Response(JSON.stringify(payload), { - headers: { "content-type": "application/json" }, - }); -} - -async function waitFor(predicate: () => boolean) { - for (let attempt = 0; attempt < 20; attempt += 1) { - if (predicate()) return; - await Promise.resolve(); - } - - throw new Error("condition was not met"); -} - -function telemetryFetch() { - return vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - - if (url.startsWith("https://prometheus.example/api/v1/query_range")) { - return jsonResponse({ - status: "success", - data: { - result: [ - { - metric: { host: "linux-infra" }, - values: [ - [1771430000, "10"], - [1771430060, "20"], - [1771430120, "42"], - ], - }, - ], - }, - }); - } - - if (url.startsWith("https://prometheus.example/api/v1/query")) { - return jsonResponse({ - status: "success", - data: { - result: [ - { - metric: { host: "linux-infra" }, - value: [1771430400, "42"], - }, - ], - }, - }); - } - - throw new Error(`Unhandled test request: ${url}`); - }); -} diff --git a/apps/web/src/server/routes/dashboard.ts b/apps/web/src/server/routes/dashboard.ts deleted file mode 100644 index c42227c..0000000 --- a/apps/web/src/server/routes/dashboard.ts +++ /dev/null @@ -1,609 +0,0 @@ -import type { DashboardDocument } from "@dimensionlab/dashboard-model"; -import { - loadDashboardRuntime, - type DashboardRuntimeOptions, - type DashboardRuntimeState, -} from "$lib/server/dashboard"; -import { - resolveDashboardDatasources, - resolveDashboardTile, - type DashboardTileReference, - type DashboardTileResolution, - type DatasourceResolutionOptions, -} from "$lib/server/datasources"; - -export interface LoadDashboardResponseOptions - extends Pick< - DashboardRuntimeOptions, - "refreshSeedDocument" | "seedIfEmpty" | "seedDocument" - >, - DatasourceResolutionOptions { - disableLiveDatasources?: boolean; - hydrateLiveDatasources?: boolean; - logTileResolution?: (event: DashboardTileResolutionLogEvent) => void; - now?: () => number; - tileCache?: DashboardTileCache; -} - -export interface DashboardEventsRouteOptions extends LoadDashboardResponseOptions { - signal?: AbortSignal; -} - -interface DashboardTileCacheEntry { - expiresAt: number; - response: DashboardTileResolution; -} - -export interface DashboardTileCache { - resolve( - key: string, - ttlMs: number, - now: number, - load: () => Promise, - ): Promise; -} - -const dashboardTileResponseHeaders = { - "Cache-Control": "private, max-age=5, stale-while-revalidate=30", -}; - -const dashboardBatchTileConcurrency = 6; - -const defaultDashboardTileCache = createDashboardTileCache(); - -interface DashboardTileCacheResult { - cache: "hit" | "miss"; - coalesced: boolean; - response: DashboardTileResolution; -} - -export interface DashboardTileResolutionLogEvent { - cache: "bypass" | "hit" | "miss"; - coalesced: boolean; - durationMs: number; - errorCategory?: string; - status: DashboardTileResolution["state"] | "error"; - tileKey: string; -} - -export interface DashboardTilesBatchResponse { - state: "ready"; - tiles: DashboardTileResolution[]; -} - -export function createDashboardTileCache(): DashboardTileCache { - const entries = new Map(); - const inFlight = new Map>(); - - return { - async resolve(key, ttlMs, now, load) { - const cached = entries.get(key); - if (cached && cached.expiresAt > now) { - return { - cache: "hit", - coalesced: false, - response: cached.response, - }; - } - - const active = inFlight.get(key); - if (active) { - return active - .then((response) => ({ - cache: "miss" as const, - coalesced: true, - response, - })) - .catch((error) => { - throw new DashboardTileCacheResolutionError(error, { - cache: "miss", - coalesced: true, - }); - }); - } - - const request = load() - .then((response) => { - if (response.state === "ready") { - entries.set(key, { - expiresAt: now + ttlMs, - response, - }); - } - - return response; - }) - .finally(() => { - inFlight.delete(key); - }); - - inFlight.set(key, request); - return request.then((response) => ({ - cache: "miss" as const, - coalesced: false, - response, - })); - }, - }; -} - -export async function loadDashboardResponse( - options: LoadDashboardResponseOptions = {}, -): Promise { - const liveHydrationEnabled = - !options.disableLiveDatasources && - process.env.DISABLE_LIVE_DATASOURCES !== "1"; - const dashboard = loadDashboardRuntime(undefined, { - refreshSeedDocument: options.refreshSeedDocument ?? true, - seedIfEmpty: options.seedIfEmpty ?? true, - seedDocument: options.seedDocument, - }); - - if (dashboard.state !== "ready") { - return dashboard; - } - - if ( - !options.hydrateLiveDatasources || - options.disableLiveDatasources || - process.env.DISABLE_LIVE_DATASOURCES === "1" - ) { - return { - ...dashboard, - liveDatasourceHydration: { - enabled: liveHydrationEnabled, - }, - }; - } - - return { - ...dashboard, - document: await resolveDashboardDatasources(dashboard.document, options), - liveDatasourceHydration: { - enabled: false, - }, - }; -} - -export async function loadDashboardTileResponse( - tile: DashboardTileReference, - options: LoadDashboardResponseOptions = {}, -): Promise { - if ( - options.disableLiveDatasources || - process.env.DISABLE_LIVE_DATASOURCES === "1" - ) { - const tileKey = dashboardTileCacheKey(tile); - const startedAt = options.now?.() ?? Date.now(); - const response = { - state: "disabled", - tile, - message: "Live datasource hydration is disabled.", - } satisfies DashboardTileResolution; - logDashboardTileResolution(options, { - cache: "bypass", - coalesced: false, - durationMs: elapsedDashboardTileMs(startedAt, options), - status: response.state, - tileKey, - }); - return response; - } - - const tileKey = dashboardTileCacheKey(tile); - const startedAt = options.now?.() ?? Date.now(); - - const dashboard = loadDashboardRuntime(undefined, { - refreshSeedDocument: options.refreshSeedDocument ?? true, - seedIfEmpty: options.seedIfEmpty ?? true, - seedDocument: options.seedDocument, - }); - - if (dashboard.state !== "ready") { - const response = { - state: "not_found", - tile, - message: `Dashboard is not ready: ${dashboard.state}`, - } satisfies DashboardTileResolution; - logDashboardTileResolution(options, { - cache: "bypass", - coalesced: false, - durationMs: elapsedDashboardTileMs(startedAt, options), - status: response.state, - tileKey, - }); - return response; - } - - const cache = options.tileCache || defaultDashboardTileCache; - const now = options.now?.() ?? Date.now(); - - try { - const result = await cache.resolve( - tileKey, - dashboardTileTtlMs(dashboard.document, tile), - now, - () => resolveDashboardTile(dashboard.document, tile, options), - ); - logDashboardTileResolution(options, { - cache: result.cache, - coalesced: result.coalesced, - durationMs: elapsedDashboardTileMs(startedAt, options), - status: result.response.state, - tileKey, - }); - return result.response; - } catch (error) { - const cacheError = dashboardTileCacheResolutionError(error); - logDashboardTileResolution(options, { - cache: cacheError?.cache ?? "miss", - coalesced: cacheError?.coalesced ?? false, - durationMs: elapsedDashboardTileMs(startedAt, options), - errorCategory: dashboardTileErrorCategory(cacheError?.cause ?? error), - status: "error", - tileKey, - }); - throw cacheError?.cause ?? error; - } -} - -export async function loadDashboardTilesResponse( - tiles: DashboardTileReference[], - options: LoadDashboardResponseOptions = {}, -): Promise { - return { - state: "ready", - tiles: await resolveDashboardTilesBatch(tiles, options), - }; -} - -export async function handleDashboardRoute(): Promise { - return Response.json(await loadDashboardResponse()); -} - -export async function handleDashboardTileRoute( - pathname: string, - options: LoadDashboardResponseOptions = {}, -): Promise { - const tile = parseDashboardTilePath(pathname); - if (!tile) { - return Response.json( - { ok: false, message: "Invalid dashboard tile route" }, - { status: 404 }, - ); - } - - const response = await loadDashboardTileResponse(tile, options); - return Response.json(response, { - status: response.state === "not_found" ? 404 : 200, - headers: dashboardTileResponseHeaders, - }); -} - -export async function handleDashboardTilesRoute( - request: Request, - options: LoadDashboardResponseOptions = {}, -): Promise { - const tiles = await parseDashboardTilesBatchRequest(request); - if (!tiles) { - return Response.json( - { ok: false, message: "Invalid dashboard tiles request" }, - { status: 400 }, - ); - } - - return Response.json(await loadDashboardTilesResponse(tiles, options), { - headers: dashboardTileResponseHeaders, - }); -} - -export async function handleDashboardEventsRoute( - options: DashboardEventsRouteOptions = {}, -): Promise { - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder(); - const dashboard = await loadDashboardResponse({ - ...options, - hydrateLiveDatasources: false, - }); - - if (dashboard.state !== "ready" || options.signal?.aborted) { - controller.close(); - return; - } - - try { - for (const tile of dashboardEventTiles(dashboard.document)) { - if (options.signal?.aborted) break; - const resolution = await loadDashboardTileResponse(tile, options); - if (resolution.state === "ready") { - controller.enqueue( - encoder.encode(dashboardTileEventChunk(resolution)), - ); - } - } - } finally { - controller.close(); - } - }, - }); - - return new Response(stream, { - headers: { - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Content-Type": "text/event-stream; charset=utf-8", - "X-Accel-Buffering": "no", - }, - }); -} - -export function dashboardTileCacheKey(tile: DashboardTileReference): string { - return JSON.stringify(tile); -} - -function logDashboardTileResolution( - options: LoadDashboardResponseOptions, - event: DashboardTileResolutionLogEvent, -): void { - if (options.logTileResolution) { - options.logTileResolution(event); - return; - } - - console.info("dashboard.tile", event); -} - -function elapsedDashboardTileMs( - startedAt: number, - options: LoadDashboardResponseOptions, -): number { - const now = options.now?.() ?? Date.now(); - return Math.max(0, now - startedAt); -} - -function dashboardTileErrorCategory(error: unknown): string { - if ( - typeof error === "object" && - error !== null && - "name" in error && - typeof error.name === "string" - ) { - return error.name; - } - - return "unknown"; -} - -async function resolveDashboardTilesBatch( - tiles: DashboardTileReference[], - options: LoadDashboardResponseOptions, -): Promise { - const results: DashboardTileResolution[] = new Array(tiles.length); - let nextIndex = 0; - - async function worker() { - while (nextIndex < tiles.length) { - const index = nextIndex; - nextIndex += 1; - results[index] = await loadDashboardTileResponse(tiles[index], options); - } - } - - await Promise.all( - Array.from( - { length: Math.min(dashboardBatchTileConcurrency, tiles.length) }, - () => worker(), - ), - ); - - return results; -} - -async function parseDashboardTilesBatchRequest( - request: Request, -): Promise { - try { - const body = await request.json() as unknown; - if ( - typeof body !== "object" || - body === null || - !("tiles" in body) || - !Array.isArray(body.tiles) - ) { - return null; - } - - const tiles = body.tiles.map(parseDashboardTileReference); - return tiles.every((tile): tile is DashboardTileReference => tile !== null) - ? tiles - : null; - } catch { - return null; - } -} - -function parseDashboardTileReference(value: unknown): DashboardTileReference | null { - if (typeof value !== "object" || value === null || !("kind" in value)) { - return null; - } - - if ( - (value.kind === "telemetry" || value.kind === "module") && - "id" in value && - typeof value.id === "string" - ) { - return { kind: value.kind, id: value.id }; - } - - if ( - value.kind === "service" && - "groupId" in value && - typeof value.groupId === "string" && - "id" in value && - typeof value.id === "string" - ) { - return { - kind: "service", - groupId: value.groupId, - id: value.id, - }; - } - - if ( - value.kind === "status" && - "stripId" in value && - typeof value.stripId === "string" && - "id" in value && - typeof value.id === "string" - ) { - return { - kind: "status", - stripId: value.stripId, - id: value.id, - }; - } - - return null; -} - -function dashboardEventTiles(document: DashboardDocument): DashboardTileReference[] { - const status = document.statusStrips.flatMap((strip) => - strip.items.map((item): DashboardTileReference => ({ - kind: "status", - stripId: strip.id, - id: item.id, - })), - ); - const telemetry = document.telemetry - .filter((card) => card.datasource?.type === "external") - .map((card): DashboardTileReference => ({ kind: "telemetry", id: card.id })); - const modules = (document.modules || []) - .filter((module) => - module.datasource?.type === "external" || - module.id === "runtime-health-summary" - ) - .map((module): DashboardTileReference => ({ kind: "module", id: module.id })); - const services = document.serviceGroups.flatMap((group) => - group.services - .filter((service) => service.datasource?.type === "external") - .map((service): DashboardTileReference => ({ - kind: "service", - groupId: group.id, - id: service.id, - })), - ); - - return [...status, ...telemetry, ...modules, ...services]; -} - -function dashboardTileEventChunk(resolution: DashboardTileResolution): string { - return [ - "event: dashboard-tile", - `data: ${JSON.stringify(resolution)}`, - "", - "", - ].join("\n"); -} - -class DashboardTileCacheResolutionError extends Error { - readonly cache: "hit" | "miss"; - readonly coalesced: boolean; - override readonly cause: unknown; - - constructor( - cause: unknown, - metadata: Pick, - ) { - super("Dashboard tile cache resolution failed"); - this.name = "DashboardTileCacheResolutionError"; - this.cause = cause; - this.cache = metadata.cache; - this.coalesced = metadata.coalesced; - } -} - -function dashboardTileCacheResolutionError( - error: unknown, -): DashboardTileCacheResolutionFailure | null { - if ( - typeof error === "object" && - error !== null && - "cache" in error && - (error.cache === "hit" || error.cache === "miss") && - "coalesced" in error && - typeof error.coalesced === "boolean" && - "cause" in error - ) { - return { - cache: error.cache, - cause: error.cause, - coalesced: error.coalesced, - }; - } - - return null; -} - -interface DashboardTileCacheResolutionFailure { - cache: "hit" | "miss"; - cause: unknown; - coalesced: boolean; -} - -function dashboardTileTtlMs( - document: DashboardDocument, - tile: DashboardTileReference, -): number { - if (tile.kind === "telemetry") return 15_000; - if (tile.kind === "service") return 30_000; - - if (tile.kind === "module") { - const module = document.modules?.find((item) => item.id === tile.id); - if ( - module?.datasource?.type === "external" && - module.datasource.adapter === "weather" - ) { - return 10 * 60_000; - } - return 30_000; - } - - if (["system-status", "uptime", "load-avg"].includes(tile.id)) { - return 30_000; - } - - return 5 * 60_000; -} - -function parseDashboardTilePath(pathname: string): DashboardTileReference | null { - const parts = pathname.split("/").filter(Boolean); - const [, dashboard, tileRoot, kind, firstId, secondId] = parts; - if (dashboard !== "dashboard" || tileRoot !== "tile" || !kind || !firstId) { - return null; - } - - const id = decodeURIComponent(firstId); - if (kind === "telemetry" || kind === "module") { - return { kind, id }; - } - - if (kind === "service" && secondId) { - return { - kind, - groupId: id, - id: decodeURIComponent(secondId), - }; - } - - if (kind === "status" && secondId) { - return { - kind, - stripId: id, - id: decodeURIComponent(secondId), - }; - } - - return null; -} diff --git a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-light-desktop-chromium-desktop-linux.png b/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-light-desktop-chromium-desktop-linux.png deleted file mode 100644 index f5d5163..0000000 Binary files a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-light-desktop-chromium-desktop-linux.png and /dev/null differ diff --git a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png b/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png deleted file mode 100644 index 48293a4..0000000 Binary files a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png and /dev/null differ diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json deleted file mode 100644 index 120931b..0000000 --- a/apps/web/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "baseUrl": ".", - "paths": { - "$lib/*": ["src/lib/*"] - }, - "types": ["node", "bun-types", "react", "react-dom", "vite/client"] - }, - "include": [ - "src/**/*.ts", - "src/**/*.tsx", - "tests/**/*.ts", - "vite.config.ts", - "playwright.config.ts", - "drizzle.config.ts" - ], - "exclude": ["build", "dist", "node_modules"] -} diff --git a/bun.lock b/bun.lock index d6915d0..36ab7d6 100644 --- a/bun.lock +++ b/bun.lock @@ -4,92 +4,45 @@ "workspaces": { "": { "name": "dimensionlab-website", - "devDependencies": { - "turbo": "^2.5.0", - }, - }, - "apps/web": { - "name": "@dimensionlab/web", - "version": "0.0.1", "dependencies": { - "@dimensionlab/dashboard-model": "workspace:*", - "@dimensionlab/ui": "workspace:*", + "@fontsource-variable/geist": "^5.2.9", + "@iconify/react": "^6.0.2", "@sinclair/typebox": "^0.34.49", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "drizzle-orm": "^0.45.2", + "lucide-react": "^1.21.0", + "radix-ui": "^1.6.0", "react": "^19.2.7", "react-dom": "^19.2.7", + "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", + "uplot": "^1.6.32", }, "devDependencies": { "@axe-core/playwright": "^4.11.3", "@playwright/test": "^1.61.0", + "@storybook/addon-a11y": "^10.4.6", + "@storybook/addon-vitest": "^10.4.6", + "@storybook/react-vite": "^10.4.6", "@tailwindcss/vite": "^4.3.1", "@types/bun": "^1.3.14", "@types/node": "^25.9.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "bun-types": "^1.3.14", "drizzle-kit": "^0.31.10", "msw": "^2.14.6", "shadcn": "^4.11.0", + "storybook": "^10.4.6", "tailwindcss": "^4.3.1", "typescript": "^6.0.3", "vite": "^8.0.16", "vitest": "^4.1.9", }, }, - "packages/dashboard-model": { - "name": "@dimensionlab/dashboard-model", - "version": "0.0.1", - "dependencies": { - "@sinclair/typebox": "^0.34.49", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", - }, - "devDependencies": { - "@types/bun": "^1.3.14", - "@types/node": "^25.9.3", - "bun-types": "^1.3.14", - "typescript": "^6.0.3", - "vitest": "^4.1.9", - }, - }, - "packages/ui": { - "name": "@dimensionlab/ui", - "version": "0.0.1", - "dependencies": { - "@fontsource-variable/geist": "^5.2.9", - "@iconify/react": "^6.0.2", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "lucide-react": "^1.21.0", - "radix-ui": "^1.6.0", - "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0", - "uplot": "^1.6.32", - }, - "devDependencies": { - "@storybook/addon-a11y": "^10.4.6", - "@storybook/addon-vitest": "^10.4.6", - "@storybook/react-vite": "^10.4.6", - "@types/bun": "^1.3.14", - "@types/node": "^25.9.3", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "bun-types": "^1.3.14", - "storybook": "^10.4.6", - "typescript": "^6.0.3", - "vite": "^8.0.16", - "vitest": "^4.1.9", - }, - "peerDependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0", - }, - }, }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], @@ -154,12 +107,6 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@dimensionlab/dashboard-model": ["@dimensionlab/dashboard-model@workspace:packages/dashboard-model"], - - "@dimensionlab/ui": ["@dimensionlab/ui@workspace:packages/ui"], - - "@dimensionlab/web": ["@dimensionlab/web@workspace:apps/web"], - "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.74.3", "", { "dependencies": { "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-+VjTIiOCApjB0K4a41+SkN18gTetmhU9UN2JD8LHeNUqo/38FmtgvdtWsW/WWN1dyYRGS7XKeAAb/ruy7x1tRw=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], @@ -176,57 +123,57 @@ "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], @@ -640,18 +587,6 @@ "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], - "@turbo/darwin-64": ["@turbo/darwin-64@2.9.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw=="], - - "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9A6TMRq/Ib+QnbhLlgkhOm+624wO4pzSQ/yQviQfWHOlFvaYxdnIAYmu2H6TS6y7kSVL0DvzNe04NbESTOzFVQ=="], - - "@turbo/linux-64": ["@turbo/linux-64@2.9.18", "", { "os": "linux", "cpu": "x64" }, "sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw=="], - - "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-Va1kXI04naMgYwqv/5Dfa36dTDx8015U7oaQAjrXa45ua9OoFjSV4OmvkML4EmXvUclQHCiBRbY8bvd0jV7eAg=="], - - "@turbo/windows-64": ["@turbo/windows-64@2.9.18", "", { "os": "win32", "cpu": "x64" }, "sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw=="], - - "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-nUdR8WqoomUys9iIQmG45TMiizJ+5BV8egSeLLZba/AWblyp3fVBcIH1kSE58OtK4g2YzbMJEth6Ttv9w5rqMA=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], @@ -690,7 +625,7 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], - "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], @@ -700,7 +635,7 @@ "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], @@ -720,7 +655,7 @@ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -734,7 +669,7 @@ "atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="], - "axe-core": ["axe-core@4.12.1", "", {}, "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA=="], + "axe-core": ["axe-core@4.11.4", "", {}, "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -764,7 +699,7 @@ "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -884,7 +819,7 @@ "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], - "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -892,7 +827,7 @@ "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], @@ -1010,7 +945,7 @@ "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], - "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -1040,7 +975,7 @@ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], @@ -1166,7 +1101,7 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], @@ -1396,8 +1331,6 @@ "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], - "turbo": ["turbo@2.9.18", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.18", "@turbo/darwin-arm64": "2.9.18", "@turbo/linux-64": "2.9.18", "@turbo/linux-arm64": "2.9.18", "@turbo/windows-64": "2.9.18", "@turbo/windows-arm64": "2.9.18" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg=="], - "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], "type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], @@ -1454,7 +1387,7 @@ "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -1472,8 +1405,6 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "@axe-core/playwright/axe-core": ["axe-core@4.11.4", "", {}, "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1498,7 +1429,7 @@ "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@storybook/addon-a11y/axe-core": ["axe-core@4.12.1", "", {}, "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], @@ -1516,6 +1447,14 @@ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + + "@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "@vitest/mocker/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1528,12 +1467,12 @@ "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], - "drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -1550,6 +1489,8 @@ "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "redent/strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], @@ -1560,20 +1501,24 @@ "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "shadcn/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + "storybook/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - "storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], - - "storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + "storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -1588,10 +1533,6 @@ "@dotenvx/dotenvx/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], - "@dotenvx/dotenvx/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], - - "@dotenvx/dotenvx/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -1638,78 +1579,128 @@ "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "drizzle-kit/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "drizzle-kit/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "drizzle-kit/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "drizzle-kit/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "drizzle-kit/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "drizzle-kit/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "drizzle-kit/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "drizzle-kit/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "drizzle-kit/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "drizzle-kit/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "drizzle-kit/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "drizzle-kit/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "drizzle-kit/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "drizzle-kit/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "drizzle-kit/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "drizzle-kit/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "drizzle-kit/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "drizzle-kit/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "drizzle-kit/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "drizzle-kit/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "drizzle-kit/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "drizzle-kit/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "drizzle-kit/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "drizzle-kit/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "drizzle-kit/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - "shadcn/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - "storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + "storybook/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - "storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + "storybook/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - "storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + "storybook/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "storybook/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "storybook/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "storybook/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "storybook/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "storybook/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "storybook/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "storybook/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "storybook/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "storybook/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "storybook/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "storybook/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "storybook/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "storybook/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "storybook/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "storybook/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "storybook/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "storybook/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "storybook/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "storybook/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "storybook/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "storybook/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "storybook/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "storybook/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + "storybook/open/wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], } } diff --git a/components.json b/components.json new file mode 100644 index 0000000..f8d8d59 --- /dev/null +++ b/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "lib": "$lib/.", + "hooks": "$lib/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/docs/superpowers/plans/2026-06-20-turbo-component-library.md b/docs/superpowers/plans/2026-06-20-turbo-component-library.md deleted file mode 100644 index 22d2ad8..0000000 --- a/docs/superpowers/plans/2026-06-20-turbo-component-library.md +++ /dev/null @@ -1,436 +0,0 @@ -# Turbo Component Library Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Convert the single-package Dimension Lab website into a Turborepo workspace where `apps/web` consumes a compiled reusable React component library from `packages/ui`. - -**Architecture:** The root becomes a private Bun workspace with Turbo orchestration. `packages/ui` owns generic reusable UI components, CSS, theme helpers, Storybook, and package-level tests. `apps/web` owns the website runtime, model/server/database logic, the model-to-UI adapter, e2e tests, and deployment container. - -**Tech Stack:** Bun workspaces, Turborepo, Vite, React 19, TypeScript, Storybook React Vite, Tailwind CSS v4, shadcn CSS, Vitest, Playwright, Drizzle ORM, Bun SQLite. - ---- - -## File Structure - -- Create root `turbo.json`: cacheable task graph for `build`, `check`, `test:unit`, `build-storybook`, `test:e2e`, and `test:qa`. -- Create root `tsconfig.base.json`: shared strict TypeScript defaults. -- Modify root `package.json`: private workspace root with `apps/*` and `packages/*`, Turbo scripts, and `turbo` dev dependency only. -- Create `packages/ui/package.json`: compiled `@dimensionlab/ui` package with code and CSS exports. -- Create `packages/ui/tsconfig.json` and `packages/ui/tsconfig.build.json`: package typecheck and declaration/JS build config. -- Create `packages/ui/src/styles.css`: library style entry importing font, uPlot CSS, tokens, and component CSS. -- Move `src/lib/ui/components/**` to `packages/ui/src/components/**`. -- Move `src/lib/ui/stories/**` to `packages/ui/src/stories/**`. -- Move `src/lib/ui/tokens.css` to `packages/ui/src/tokens.css`. -- Move `src/lib/ui/theme.ts`, `types.ts`, `format.ts`, `fixtures.ts`, and `index.ts` to `packages/ui/src/**`. -- Move `src/lib/ui/components/styles.css` to `packages/ui/src/components/styles.css`. -- Move `.storybook/**` to `packages/ui/.storybook/**`. -- Move unused shadcn primitives from `src/lib/components/ui/**` to `packages/ui/src/primitives/**` and update their `cn` import to package-local `src/utils.ts`. -- Move `src/lib/utils.ts` to `packages/ui/src/utils.ts`. -- Move app runtime files into `apps/web`: `src/App.tsx`, `src/main.tsx`, `src/app.css`, `src/server/**`, `src/lib/model/**`, `src/lib/server/**`, `src/lib/testing/**`, `src/vite-env.d.ts`, `src/page.test.tsx`, `src/server/dev.ts`, `tests/**`, `drizzle/**`, `Containerfile`, `index.html`, `playwright.config.ts`, `vite.config.ts`, `drizzle.config.ts`, and app-specific README/deployment files. -- Move `src/lib/ui/model-renderer.ts` and `model-renderer.test.ts` into `apps/web/src/lib/ui-adapter/**`. -- Create `apps/web/package.json`, `apps/web/tsconfig.json`, `apps/web/vite.config.ts`, and `apps/web/playwright.config.ts`. -- Update `apps/web/src/App.tsx` to import components/types from `@dimensionlab/ui` and import the adapter from `$lib/ui-adapter/model-renderer`. -- Update `apps/web/src/app.css` to import `@dimensionlab/ui/styles.css` instead of local UI CSS files. -- Update tests that read paths so package boundary tests inspect `packages/ui` and app tests inspect `apps/web`. -- Update `README.md` to describe the workspace commands, package boundaries, Storybook location, and deployment path. - -## Task 1: Workspace And Boundary Tests - -**Files:** -- Modify: `package.json` -- Create: `turbo.json` -- Create: `tsconfig.base.json` -- Create: `packages/ui/package.json` -- Create: `packages/ui/tsconfig.json` -- Create: `packages/ui/tsconfig.build.json` -- Create: `apps/web/package.json` -- Create: `apps/web/tsconfig.json` -- Create: `apps/web/src/lib/workspace-boundary.test.ts` - -- [ ] **Step 1: Write the failing workspace boundary test** - -Create `apps/web/src/lib/workspace-boundary.test.ts`: - -```ts -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, test } from "vitest"; - -const root = join(import.meta.dir, "../../../.."); - -describe("workspace boundaries", () => { - test("declares the root as a turbo-managed bun workspace", () => { - const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { - private?: boolean; - scripts?: Record; - workspaces?: string[]; - }; - - expect(packageJson.private).toBe(true); - expect(packageJson.workspaces).toEqual(["apps/*", "packages/*"]); - expect(packageJson.scripts?.build).toBe("turbo build"); - expect(existsSync(join(root, "turbo.json"))).toBe(true); - }); - - test("keeps the website app and reusable UI library as separate packages", () => { - const webPackage = JSON.parse( - readFileSync(join(root, "apps/web/package.json"), "utf8"), - ) as { dependencies?: Record; name?: string }; - const uiPackage = JSON.parse( - readFileSync(join(root, "packages/ui/package.json"), "utf8"), - ) as { exports?: Record; name?: string }; - - expect(webPackage.name).toBe("@dimensionlab/web"); - expect(webPackage.dependencies?.["@dimensionlab/ui"]).toBe("workspace:*"); - expect(uiPackage.name).toBe("@dimensionlab/ui"); - expect(uiPackage.exports).toHaveProperty("."); - expect(uiPackage.exports).toHaveProperty("./styles.css"); - }); -}); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: - -```sh -bunx vitest run apps/web/src/lib/workspace-boundary.test.ts -``` - -Expected: FAIL because `apps/web`, `packages/ui`, and `turbo.json` do not exist. - -- [ ] **Step 3: Add minimal workspace manifests** - -Create root `package.json` as the workspace orchestrator: - -```json -{ - "name": "dimensionlab", - "version": "0.0.1", - "private": true, - "type": "module", - "packageManager": "bun@1.3.14", - "workspaces": ["apps/*", "packages/*"], - "scripts": { - "dev": "turbo dev --filter=@dimensionlab/web", - "build": "turbo build", - "preview": "bun run --cwd apps/web preview", - "storybook": "turbo storybook --filter=@dimensionlab/ui", - "build-storybook": "turbo build-storybook --filter=@dimensionlab/ui", - "check": "turbo check", - "test": "turbo test:unit", - "test:unit": "turbo test:unit", - "test:e2e": "turbo test:e2e --filter=@dimensionlab/web", - "test:qa": "turbo test:qa", - "db:generate": "bun run --cwd apps/web db:generate", - "db:check": "bun run --cwd apps/web db:check" - }, - "devDependencies": { - "turbo": "^2.5.0" - } -} -``` - -Create `turbo.json`: - -```json -{ - "$schema": "https://turbo.build/schema.json", - "tasks": { - "build": { - "dependsOn": ["^build"], - "outputs": ["dist/**", "build/**"] - }, - "check": { - "dependsOn": ["^build"], - "outputs": [] - }, - "test:unit": { - "dependsOn": ["^build"], - "outputs": [] - }, - "build-storybook": { - "dependsOn": ["^build"], - "outputs": ["storybook-static/**"] - }, - "test:e2e": { - "dependsOn": ["build", "^build"], - "outputs": ["test-results/**", "playwright-report/**"] - }, - "test:qa": { - "dependsOn": ["check", "test:unit", "build", "build-storybook", "test:e2e"], - "outputs": [] - }, - "dev": { - "cache": false, - "persistent": true - }, - "storybook": { - "cache": false, - "persistent": true - } - } -} -``` - -Create minimal `packages/ui/package.json` with `@dimensionlab/ui` exports and -minimal `apps/web/package.json` with `@dimensionlab/ui` as a workspace -dependency. Move the full dependency lists in Task 2 and Task 3. - -- [ ] **Step 4: Run the boundary test** - -Run: - -```sh -bunx vitest run apps/web/src/lib/workspace-boundary.test.ts -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```sh -git add package.json turbo.json tsconfig.base.json apps/web/package.json apps/web/tsconfig.json apps/web/src/lib/workspace-boundary.test.ts packages/ui/package.json packages/ui/tsconfig.json packages/ui/tsconfig.build.json -git commit -m "build: add turbo workspace manifests" -``` - -## Task 2: Extract The UI Package - -**Files:** -- Move: `src/lib/ui/components/**` to `packages/ui/src/components/**` -- Move: `src/lib/ui/stories/**` to `packages/ui/src/stories/**` -- Move: `src/lib/ui/{index.ts,types.ts,theme.ts,format.ts,fixtures.ts,tokens.css}` to `packages/ui/src/**` -- Move: `.storybook/**` to `packages/ui/.storybook/**` -- Move: `src/lib/components/ui/**` to `packages/ui/src/primitives/**` -- Move: `src/lib/utils.ts` to `packages/ui/src/utils.ts` -- Create: `packages/ui/src/styles.css` -- Modify: `packages/ui/src/index.ts` -- Modify: `packages/ui/src/storybook.test.ts` -- Modify: `packages/ui/src/content-boundary.test.ts` - -- [ ] **Step 1: Write the failing UI isolation assertion** - -Update the package boundary tests to read from `packages/ui/src` and assert no -imports from `apps/web`, `$lib/server`, or `$lib/model` exist: - -```ts -expect(source).not.toMatch(/from ["'](?:apps\/web|\$lib\/server|\$lib\/model)/); -``` - -Run: - -```sh -bunx vitest run packages/ui/src/content-boundary.test.ts -``` - -Expected: FAIL until the files move and the app-specific adapter is removed. - -- [ ] **Step 2: Move generic UI files** - -Run mechanical moves with `git mv`. Move `model-renderer.ts` out of the UI -package in Task 3 rather than into `packages/ui`. - -- [ ] **Step 3: Add the UI style entry** - -Create `packages/ui/src/styles.css`: - -```css -@import "@fontsource-variable/geist"; -@import "uplot/dist/uPlot.min.css"; -@import "./tokens.css"; -@import "./components/styles.css"; -``` - -- [ ] **Step 4: Make package imports relative or package-local** - -Update moved primitive files to import `cn` from `../utils`. -Remove `dashboardDocumentToUiDashboard` from `packages/ui/src/index.ts`. - -- [ ] **Step 5: Build the package** - -Run: - -```sh -bun run --cwd packages/ui build -bun run --cwd packages/ui check -bun run --cwd packages/ui test:unit -``` - -Expected: PASS and `packages/ui/dist` contains `index.js`, `index.d.ts`, and -CSS files. - -- [ ] **Step 6: Commit** - -```sh -git add packages/ui src/lib/ui src/lib/components src/lib/utils.ts -git commit -m "refactor(ui): extract reusable component package" -``` - -## Task 3: Move The Web App Workspace - -**Files:** -- Move: `src/**` app files that are not reusable UI to `apps/web/src/**` -- Move: `tests/**` to `apps/web/tests/**` -- Move: `drizzle/**` to `apps/web/drizzle/**` -- Move: `Containerfile` to `apps/web/Containerfile` -- Move: `index.html`, `vite.config.ts`, `playwright.config.ts`, `drizzle.config.ts` to `apps/web/**` -- Create: `apps/web/src/lib/ui-adapter/model-renderer.ts` -- Modify: `apps/web/src/App.tsx` -- Modify: `apps/web/src/app.css` - -- [ ] **Step 1: Move the model adapter out of UI** - -Move `src/lib/ui/model-renderer.ts` to -`apps/web/src/lib/ui-adapter/model-renderer.ts` and update imports from -`$lib/model` plus UI types from `@dimensionlab/ui`. - -- [ ] **Step 2: Move app runtime and tests** - -Use `git mv` for app/server/model/test/deployment files into `apps/web`. - -- [ ] **Step 3: Update app imports** - -In `apps/web/src/App.tsx`, import UI components and types from -`@dimensionlab/ui`, and import `dashboardDocumentToUiDashboard` from -`$lib/ui-adapter/model-renderer`. - -In `apps/web/src/app.css`, replace local UI imports with: - -```css -@import "tailwindcss"; -@import "tw-animate-css"; -@import "shadcn/tailwind.css"; -@import "@dimensionlab/ui/styles.css"; -``` - -- [ ] **Step 4: Run app typecheck and unit tests** - -Run: - -```sh -bun run --cwd apps/web check -bun run --cwd apps/web test:unit -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```sh -git add apps/web src tests drizzle Containerfile index.html vite.config.ts playwright.config.ts drizzle.config.ts -git commit -m "refactor(web): move website into app workspace" -``` - -## Task 4: Wire Turbo, Storybook, Playwright, And Container - -**Files:** -- Modify: `apps/web/playwright.config.ts` -- Modify: `apps/web/Containerfile` -- Modify: `apps/web/vite.config.ts` -- Modify: `packages/ui/.storybook/main.ts` -- Modify: `packages/ui/.storybook/preview.ts` -- Modify: `tests/e2e/storybook-server.ts` after move to `apps/web/tests/e2e/storybook-server.ts` -- Modify: `README.md` - -- [ ] **Step 1: Update Playwright commands for workspaces** - -In `apps/web/playwright.config.ts`, make the web server command build from the -workspace root or app directory consistently: - -```ts -command: `DISABLE_LIVE_DATASOURCES=1 bun run build && DISABLE_LIVE_DATASOURCES=1 DATABASE_URL=${databaseUrl} HOST=127.0.0.1 PORT=${port} bun build/index.js` -``` - -This command runs inside `apps/web` when invoked through the app package script. - -- [ ] **Step 2: Update Storybook package paths** - -`packages/ui/.storybook/main.ts` should use `../src/**/*.stories.@(js|ts|tsx)`. -`packages/ui/.storybook/preview.ts` should import `../src/styles.css` and use -generic MSW handlers only if they are moved into the UI package. - -- [ ] **Step 3: Update the container build** - -`apps/web/Containerfile` should build from the repository root context or copy -only the workspace files it needs. Preserve the runtime command: - -```dockerfile -CMD ["bun", "build/index.js"] -``` - -- [ ] **Step 4: Run full root checks** - -Run: - -```sh -bun install -bun run check -bun run test:unit -bun run build -bun run build-storybook -bun run test:e2e -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```sh -git add README.md apps/web packages/ui package.json turbo.json bun.lock -git commit -m "build: wire turbo workspace qa" -``` - -## Task 5: Final QA, PR, Review, Merge, Deploy - -**Files:** -- No planned source edits unless verification finds blockers. - -- [ ] **Step 1: Run release gate** - -Run: - -```sh -bun run test:qa -``` - -Expected: PASS. - -- [ ] **Step 2: Push and open PR** - -Run: - -```sh -git push -u origin codex/turbo-component-library -``` - -Open a ready PR against `main` titled: - -```text -refactor: migrate dashboard to turbo component library -``` - -- [ ] **Step 3: Independent review** - -Send an independent reviewer to inspect the PR diff against the objective: -Turbo repo, `apps/web`, compiled `packages/ui`, reusable components removed -from app, Storybook with UI package, root QA passing, no server behavior change. - -- [ ] **Step 4: Resolve blockers** - -For each blocking review finding, write or update a failing test first, verify -the failure, implement the fix, run targeted checks, commit, push, and re-review. - -- [ ] **Step 5: Merge and deploy** - -When review and checks are clean, merge the PR into `main`, sync the production -checkout, rebuild the Podman image, restart `dimensionlab-website.service`, and -verify `https://dimensionlab.net/` with a browser smoke check. - -## Self-Review - -- Spec coverage: every completion criterion in the design maps to Task 1 through - Task 5. -- Placeholder scan: no task says TBD, TODO, or "add tests" without commands. -- Type consistency: package names are consistently `@dimensionlab/ui` and - `@dimensionlab/web`; the app adapter path is consistently - `$lib/ui-adapter/model-renderer`. diff --git a/docs/superpowers/specs/2026-06-20-turbo-component-library-design.md b/docs/superpowers/specs/2026-06-20-turbo-component-library-design.md deleted file mode 100644 index ba0fefc..0000000 --- a/docs/superpowers/specs/2026-06-20-turbo-component-library-design.md +++ /dev/null @@ -1,186 +0,0 @@ -# Turbo Component Library Migration Design - -## Context - -The current Dimension Lab website is a single Bun/Vite React package. It owns -the browser app, Bun production server, dashboard model, persistence, datasource -adapters, reusable UI components, Storybook, Playwright checks, and container -deployment from one `package.json`. - -The UI components are already mostly generic and content-free under -`src/lib/ui`, but they are not reusable by another project because they live -inside the app package, depend on app path aliases, and share the app build, -test, and Storybook configuration. One file in that area, -`src/lib/ui/model-renderer.ts`, imports the Dimension Lab dashboard model and -therefore is an app adapter, not reusable component-library code. - -The migration goal is to turn the repository into a Turborepo workspace where -the Dimension Lab website consumes a separate reusable React component package. - -## Target Repository Shape - -Use one application workspace and one component-library workspace: - -```text -. -├── apps/ -│ └── web/ -│ ├── src/ -│ ├── tests/ -│ ├── drizzle/ -│ ├── Containerfile -│ └── package.json -├── packages/ -│ └── ui/ -│ ├── src/ -│ ├── .storybook/ -│ ├── package.json -│ └── tsconfig.json -├── package.json -├── turbo.json -├── tsconfig.base.json -└── bun.lock -``` - -The root package is private and contains only workspace orchestration: -workspaces, Turbo scripts, shared dev dependencies where useful, and the lock -file. Runtime dependencies belong to the workspace that imports them. - -## Package Ownership - -`packages/ui` is a compiled React library named `@dimensionlab/ui`. - -It owns: - -- Reusable React components currently under `src/lib/ui/components`. -- UI CSS tokens and component styles. -- Theme helpers currently under `src/lib/ui/theme.ts`. -- Generic UI prop/data types currently under `src/lib/ui/types.ts`. -- Generic formatting helpers currently under `src/lib/ui/format.ts`. -- Generic Storybook stories and story fixtures. -- UI render tests, boundary tests, and Storybook inventory tests. - -It must not import from the website app, the dashboard model, server modules, -database modules, datasource modules, or deployment files. - -The library publishes explicit package exports: - -- `@dimensionlab/ui` for component and type exports. -- `@dimensionlab/ui/styles.css` for the combined token/component CSS entry. -- Optional explicit subpath exports for future direct imports where useful. - -The compiled output goes to `packages/ui/dist` and includes JavaScript, -declaration files, and copied CSS. The package stays private for now but is -structured so it can later be published or moved into another Dimension Lab repo -without taking the website runtime with it. - -`apps/web` owns: - -- The Vite React browser app. -- The Bun production server and API routes. -- Dashboard model, fixtures, schema, validation, and model migrations. -- Drizzle/SQLite persistence and checked-in SQL migrations. -- Datasource adapters and runtime dashboard loading. -- Agent dashboard configuration endpoint. -- The `dashboardDocumentToUiDashboard` adapter that maps the app model to UI - package props. -- Playwright e2e tests and deployment container. - -## Why A Compiled Package - -The component package should be compiled rather than a just-in-time source -package. This is slightly more setup, but it better fits reuse outside the -current app because consumers can import stable JavaScript and declarations -instead of relying on their bundler to transpile this repo's TypeScript source. -It also gives Turbo a cacheable `@dimensionlab/ui#build` task. - -## Build And Task Graph - -Root scripts delegate through Turbo: - -- `bun run dev` runs the web dev server and any required dependency tasks. -- `bun run build` runs package builds in dependency order. -- `bun run check` runs TypeScript checks for all workspaces. -- `bun run test:unit` runs Vitest unit tests for all workspaces. -- `bun run build-storybook` builds Storybook from `packages/ui`. -- `bun run test:e2e` runs the web app Playwright suite. -- `bun run test:qa` is the full release gate. - -`turbo.json` defines `build`, `check`, `test:unit`, `build-storybook`, -`test:e2e`, and `test:qa` tasks. Build outputs include `dist/**`, -`storybook-static/**`, and `build/**` as appropriate. - -The web app depends on `@dimensionlab/ui` using Bun workspace syntax. The web -Vite config aliases `$lib` to `apps/web/src/lib`; the UI package should not use -that app alias. - -## Storybook - -Storybook moves with the component package. It should load -`@dimensionlab/ui/styles.css`, use React Vite Storybook, and keep the existing -generic story inventory. Environment-specific Dimension Lab labels, hostnames, -links, fallback values, and datasource names remain forbidden in package UI -source and stories. - -The repository should no longer have a root Storybook tied to the web app. - -## Deployment - -The deployed website remains the same service from the outside: - -- Production command remains `bun build/index.js` inside the runtime image. -- The container still exposes port `3000` and mounts `/data`. -- Runtime environment variables and database behavior remain unchanged. - -The `Containerfile` moves to `apps/web/Containerfile` or remains root with -updated workspace-aware copy/build steps. The chosen layout must preserve the -existing Podman service contract used by `dimensionlab-website.service`. - -## Testing Strategy - -The migration must add or update tests that prove the new boundaries: - -- The root package is a Bun workspace with `apps/*` and `packages/*`. -- The web app imports UI from `@dimensionlab/ui`, not from local copied - component files. -- `packages/ui` does not import from `apps/web`, `$lib/server`, `$lib/model`, - or any website runtime module. -- `dashboardDocumentToUiDashboard` lives in `apps/web` and is tested there. -- Storybook inventory is evaluated against `packages/ui`. -- The full QA gate still covers typecheck, unit tests, app build, Storybook - build, and Playwright desktop/mobile checks. - -## Completion Criteria - -The migration is complete only when current evidence proves all of these: - -- Root `package.json` is a private workspace root with Turbo scripts. -- `turbo.json` exists and models the workspace task graph. -- The web application lives under `apps/web`. -- The reusable React component library lives under `packages/ui`. -- `packages/ui/package.json` is named `@dimensionlab/ui` and has compiled - exports for code, types, and CSS. -- The web app depends on `@dimensionlab/ui` through the workspace. -- Reusable components and Storybook have been removed from the web app package. -- App-specific model/server/database/datasource code has not moved into - `packages/ui`. -- `dashboardDocumentToUiDashboard` is outside the UI package. -- `bun run check` passes from the root. -- `bun run test:unit` passes from the root. -- `bun run build` passes from the root. -- `bun run build-storybook` passes from the root. -- `bun run test:e2e` passes from the root. -- The production container can still be built and run with the same service - contract. - -## Migration Approach - -Implement this on branch `codex/turbo-component-library` in focused commits: - -1. Add the workspace/Turbo scaffolding and boundary tests. -2. Move UI code and Storybook to `packages/ui`. -3. Move the app runtime into `apps/web` and wire it to `@dimensionlab/ui`. -4. Move the model-to-UI adapter into the web app. -5. Update build, test, Playwright, Storybook, README, and container paths. -6. Run the full QA gate, push a ready PR, perform independent review, resolve - blockers, merge to `main`, and deploy only when checks and review are clean. diff --git a/apps/web/drizzle.config.ts b/drizzle.config.ts similarity index 100% rename from apps/web/drizzle.config.ts rename to drizzle.config.ts diff --git a/apps/web/drizzle/0000_dashboard_persistence.sql b/drizzle/0000_dashboard_persistence.sql similarity index 100% rename from apps/web/drizzle/0000_dashboard_persistence.sql rename to drizzle/0000_dashboard_persistence.sql diff --git a/apps/web/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json similarity index 100% rename from apps/web/drizzle/meta/0000_snapshot.json rename to drizzle/meta/0000_snapshot.json diff --git a/apps/web/drizzle/meta/_journal.json b/drizzle/meta/_journal.json similarity index 100% rename from apps/web/drizzle/meta/_journal.json rename to drizzle/meta/_journal.json diff --git a/apps/web/index.html b/index.html similarity index 100% rename from apps/web/index.html rename to index.html diff --git a/package.json b/package.json index e33d3aa..f1cf4ab 100644 --- a/package.json +++ b/package.json @@ -1,28 +1,63 @@ { - "name": "dimensionlab", + "name": "dimensionlab-website", "version": "0.0.1", "private": true, "type": "module", - "packageManager": "bun@1.3.14", - "workspaces": [ - "apps/*", - "packages/*" - ], "scripts": { - "dev": "turbo run dev --filter=@dimensionlab/web", - "build": "turbo run build", - "preview": "turbo run preview --filter=@dimensionlab/web", - "storybook": "turbo run storybook --filter=@dimensionlab/ui", - "build-storybook": "turbo run build-storybook --filter=@dimensionlab/ui", - "check": "turbo run check", - "test": "turbo run test:unit", - "test:unit": "turbo run test:unit", - "test:e2e": "turbo run test:e2e --filter=@dimensionlab/web", - "test:qa": "turbo run check test:unit build @dimensionlab/ui#build-storybook @dimensionlab/web#test:e2e", - "db:generate": "turbo run db:generate --filter=@dimensionlab/web", - "db:check": "turbo run db:check --filter=@dimensionlab/web" + "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", + "build-storybook": "storybook build", + "check": "tsc --noEmit", + "test": "vitest run", + "test:unit": "vitest run", + "test:e2e": "env -u NO_COLOR playwright test", + "test:qa": "bun run check && bun run test:unit && bun run build && bun run build-storybook && bun run test:e2e", + "db:generate": "drizzle-kit generate", + "db:check": "drizzle-kit check" + }, + "dependencies": { + "@fontsource-variable/geist": "^5.2.9", + "@iconify/react": "^6.0.2", + "@sinclair/typebox": "^0.34.49", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "drizzle-orm": "^0.45.2", + "lucide-react": "^1.21.0", + "radix-ui": "^1.6.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0", + "uplot": "^1.6.32" }, "devDependencies": { - "turbo": "^2.5.0" + "@axe-core/playwright": "^4.11.3", + "@playwright/test": "^1.61.0", + "@storybook/addon-a11y": "^10.4.6", + "@storybook/addon-vitest": "^10.4.6", + "@storybook/react-vite": "^10.4.6", + "@tailwindcss/vite": "^4.3.1", + "@types/bun": "^1.3.14", + "@types/node": "^25.9.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "drizzle-kit": "^0.31.10", + "msw": "^2.14.6", + "shadcn": "^4.11.0", + "storybook": "^10.4.6", + "tailwindcss": "^4.3.1", + "typescript": "^6.0.3", + "vite": "^8.0.16", + "vitest": "^4.1.9" + }, + "msw": { + "workerDirectory": [ + "static" + ] } } diff --git a/packages/dashboard-model/package.json b/packages/dashboard-model/package.json deleted file mode 100644 index ae9a49b..0000000 --- a/packages/dashboard-model/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@dimensionlab/dashboard-model", - "version": "0.0.1", - "private": true, - "type": "module", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "development": "./src/index.ts", - "default": "./dist/index.js" - }, - "./fixtures": { - "types": "./dist/fixtures/index.d.ts", - "development": "./src/fixtures/index.ts", - "default": "./dist/fixtures/index.js" - } - }, - "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json", - "check": "tsc --noEmit", - "test": "vitest run", - "test:unit": "vitest run" - }, - "dependencies": { - "@sinclair/typebox": "^0.34.49", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1" - }, - "devDependencies": { - "@types/bun": "^1.3.14", - "@types/node": "^25.9.3", - "bun-types": "^1.3.14", - "typescript": "^6.0.3", - "vitest": "^4.1.9" - } -} diff --git a/packages/dashboard-model/src/fixtures/index.ts b/packages/dashboard-model/src/fixtures/index.ts deleted file mode 100644 index f62d646..0000000 --- a/packages/dashboard-model/src/fixtures/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { genericDashboardFixture } from "./generic"; diff --git a/packages/dashboard-model/tsconfig.build.json b/packages/dashboard-model/tsconfig.build.json deleted file mode 100644 index 5d3e7ce..0000000 --- a/packages/dashboard-model/tsconfig.build.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "declaration": true, - "emitDeclarationOnly": false, - "noEmit": false, - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src/**/*.ts"], - "exclude": ["dist", "node_modules", "src/**/*.test.ts"] -} diff --git a/packages/dashboard-model/tsconfig.json b/packages/dashboard-model/tsconfig.json deleted file mode 100644 index 26cb1c4..0000000 --- a/packages/dashboard-model/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "baseUrl": ".", - "types": ["node", "bun-types"] - }, - "include": ["src/**/*.ts"], - "exclude": ["dist", "node_modules"] -} diff --git a/packages/ui b/packages/ui deleted file mode 160000 index a7a4720..0000000 --- a/packages/ui +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a7a472083555152b8e1a2dc018d3be5b3b10d60b diff --git a/apps/web/playwright.config.ts b/playwright.config.ts similarity index 81% rename from apps/web/playwright.config.ts rename to playwright.config.ts index ac0fe5e..3593452 100644 --- a/apps/web/playwright.config.ts +++ b/playwright.config.ts @@ -21,13 +21,13 @@ export default defineConfig({ }, webServer: [ { - command: `DISABLE_LIVE_DATASOURCES=1 DATABASE_URL=${databaseUrl} HOST=127.0.0.1 PORT=${port} bun build/index.js`, + command: `DISABLE_LIVE_DATASOURCES=1 bun run build && DISABLE_LIVE_DATASOURCES=1 DATABASE_URL=${databaseUrl} HOST=127.0.0.1 PORT=${port} bun build/index.js`, url: baseURL, reuseExistingServer: false, timeout: 120_000, }, { - command: `cd ../.. && STORYBOOK_STATIC_PORT=${storybookPort} bun apps/web/tests/e2e/storybook-server.ts`, + command: `bun run build-storybook && STORYBOOK_STATIC_PORT=${storybookPort} bun tests/e2e/storybook-server.ts`, url: storybookURL, reuseExistingServer: false, timeout: 120_000, diff --git a/scripts/deploy-dimensionlab-website.sh b/scripts/deploy-dimensionlab-website.sh deleted file mode 100755 index 4fac4cd..0000000 --- a/scripts/deploy-dimensionlab-website.sh +++ /dev/null @@ -1,362 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -APP_NAME="${APP_NAME:-dimensionlab-website}" -SERVICE_NAME="${SERVICE_NAME:-dimensionlab-website.service}" -CONTAINER_NAME="${CONTAINER_NAME:-dimensionlab-website}" -IMAGE_REPO="${IMAGE_REPO:-localhost/dimensionlab-website}" -CONTAINERFILE="${CONTAINERFILE:-apps/web/Containerfile}" -PUBLIC_URL="${PUBLIC_URL:-https://dimensionlab.net/}" -TILE_BATCH_URL="${TILE_BATCH_URL:-https://dimensionlab.net/api/dashboard/tiles}" -DEPLOY_RESTART_STRATEGY="${DEPLOY_RESTART_STRATEGY:-auto}" -DEPLOY_SMOKE_TIMEOUT_SECONDS="${DEPLOY_SMOKE_TIMEOUT_SECONDS:-120}" -DEPLOY_CONTAINER_START_TIMEOUT_SECONDS="${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS:-90}" - -dry_run=false -rollback_tag="" -release_tag="" -latest_tag="${IMAGE_REPO}:latest" -container_cli="" -deployment_started=false -rollback_done=false -rollback_in_progress=false - -usage() { - cat <&2 - if [ "${deployment_started:-false}" = "true" ] && [ "${rollback_in_progress:-false}" != "true" ]; then - rollback || true - fi - exit 1 -} - -run() { - if "$dry_run"; then - printf '[deploy:%s] DRY-RUN:' "$APP_NAME" - printf ' %q' "$@" - printf '\n' - return 0 - fi - - "$@" -} - -for arg in "$@"; do - case "$arg" in - --dry-run) - dry_run=true - ;; - -h|--help) - usage - exit 0 - ;; - *) - usage >&2 - fail "unknown argument: $arg" - ;; - esac -done - -deployment_ref() { - printf '%s' "${DEPLOY_REF:-${GITHUB_REF:-${FORGEJO_REF:-}}}" -} - -deployment_event() { - printf '%s' "${DEPLOY_EVENT_NAME:-${GITHUB_EVENT_NAME:-${FORGEJO_EVENT_NAME:-}}}" -} - -require_main_push() { - local event - local ref - - event="$(deployment_event)" - ref="$(deployment_ref)" - - if [ -n "$event" ] && [ "$event" != "push" ]; then - fail "refusing to deploy for event '$event'; production deploys only run for push" - fi - - if [ -n "$ref" ]; then - [ "$ref" = "refs/heads/main" ] || fail "refusing to deploy ref '$ref'; expected refs/heads/main" - return 0 - fi - - local branch - branch="$(git branch --show-current 2>/dev/null || true)" - [ "$branch" = "main" ] || fail "refusing to deploy branch '$branch'; expected main" -} - -select_container_cli() { - if [ -n "${DEPLOY_CONTAINER_CLI:-}" ]; then - command -v "$DEPLOY_CONTAINER_CLI" >/dev/null 2>&1 || fail "container CLI not found: $DEPLOY_CONTAINER_CLI" - container_cli="$DEPLOY_CONTAINER_CLI" - return 0 - fi - - if command -v podman >/dev/null 2>&1; then - container_cli="podman" - return 0 - fi - - if command -v docker >/dev/null 2>&1; then - container_cli="docker" - return 0 - fi - - fail "podman or docker is required" -} - -current_sha() { - if [ -n "${DEPLOY_SHA:-${GITHUB_SHA:-}}" ]; then - printf '%s' "${DEPLOY_SHA:-${GITHUB_SHA:-}}" - return 0 - fi - - git rev-parse HEAD -} - -tag_existing_latest_for_rollback() { - rollback_tag="${IMAGE_REPO}:rollback-$(date -u +%Y%m%d%H%M%S)" - if "$container_cli" image inspect "$latest_tag" >/dev/null 2>&1; then - log "tagging current latest image as $rollback_tag" - run "$container_cli" tag "$latest_tag" "$rollback_tag" - else - log "no existing $latest_tag image found; rollback image tag will not be created" - rollback_tag="" - fi -} - -container_systemd_unit() { - "$container_cli" inspect "$CONTAINER_NAME" \ - --format '{{ index .Config.Labels "PODMAN_SYSTEMD_UNIT" }}' 2>/dev/null || true -} - -require_container_managed_by_service() { - local unit - - if "$dry_run"; then - log "DRY-RUN: would require $CONTAINER_NAME to be managed by $SERVICE_NAME" - return 0 - fi - - unit="$(container_systemd_unit)" - [ "$unit" = "$SERVICE_NAME" ] || fail "refusing to stop $CONTAINER_NAME; expected PODMAN_SYSTEMD_UNIT=$SERVICE_NAME, got '${unit:-unset}'" -} - -validate_restart_strategy() { - case "$DEPLOY_RESTART_STRATEGY" in - systemctl) - if ! "$dry_run" && ! systemctl --user show "$SERVICE_NAME" >/dev/null 2>&1; then - fail "systemctl --user cannot access $SERVICE_NAME" - fi - ;; - quadlet-container|kill-container) - require_container_managed_by_service - ;; - auto) - if "$dry_run"; then - log "DRY-RUN: would validate automatic restart strategy" - elif ! command -v systemctl >/dev/null 2>&1 || ! systemctl --user show "$SERVICE_NAME" >/dev/null 2>&1; then - require_container_managed_by_service - fi - ;; - *) - fail "unknown DEPLOY_RESTART_STRATEGY: $DEPLOY_RESTART_STRATEGY" - ;; - esac -} - -initialize_submodules() { - log "initializing submodules" - run git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/" - run git submodule update --init --recursive -} - -build_image() { - local sha - local short_sha - - sha="$(current_sha)" - short_sha="${sha:0:12}" - release_tag="${IMAGE_REPO}:${short_sha}" - - [ -f "$CONTAINERFILE" ] || fail "containerfile not found: $CONTAINERFILE" - - log "building $release_tag and $latest_tag from $CONTAINERFILE" - run "$container_cli" build -f "$CONTAINERFILE" -t "$release_tag" -t "$latest_tag" . -} - -restart_service() { - log "restarting $SERVICE_NAME with strategy $DEPLOY_RESTART_STRATEGY" - - case "$DEPLOY_RESTART_STRATEGY" in - systemctl) - run systemctl --user restart "$SERVICE_NAME" - ;; - quadlet-container|kill-container) - require_container_managed_by_service - run "$container_cli" stop "$CONTAINER_NAME" - ;; - auto) - if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active "$SERVICE_NAME" >/dev/null 2>&1; then - run systemctl --user restart "$SERVICE_NAME" - else - require_container_managed_by_service - run "$container_cli" stop "$CONTAINER_NAME" - fi - ;; - *) - fail "unknown DEPLOY_RESTART_STRATEGY: $DEPLOY_RESTART_STRATEGY" - ;; - esac -} - -latest_image_id() { - "$container_cli" image inspect "$latest_tag" --format '{{.Id}}' 2>/dev/null || true -} - -container_image_id() { - "$container_cli" inspect "$CONTAINER_NAME" --format '{{.Image}}' 2>/dev/null || true -} - -container_running() { - local running - running="$("$container_cli" inspect "$CONTAINER_NAME" --format '{{.State.Running}}' 2>/dev/null || true)" - [ "$running" = "true" ] -} - -wait_for_container_restart() { - local expected_image - - if "$dry_run"; then - log "DRY-RUN: would wait for $CONTAINER_NAME to run $latest_tag" - return 0 - fi - - expected_image="$(latest_image_id)" - [ -n "$expected_image" ] || fail "could not resolve image id for $latest_tag" - - wait_for_container_image "$expected_image" "new image" || fail "$CONTAINER_NAME did not restart on $latest_tag within ${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS}s" -} - -wait_for_container_image() { - local expected_image="$1" - local label="$2" - local deadline - - deadline=$((SECONDS + DEPLOY_CONTAINER_START_TIMEOUT_SECONDS)) - - while [ "$SECONDS" -lt "$deadline" ]; do - if container_running && [ "$(container_image_id)" = "$expected_image" ]; then - log "$CONTAINER_NAME is running the $label" - return 0 - fi - - sleep 2 - done - - return 1 -} - -smoke_get() { - local url="$1" - curl -fsS --max-time 10 -o /dev/null "$url" -} - -smoke_tiles() { - local response - - response="$( - curl -fsS --max-time 20 \ - -H "content-type: application/json" \ - --data '{"tiles":[{"kind":"status","stripId":"footer-status","id":"system-status"}]}' \ - "$TILE_BATCH_URL" - )" - - [[ "$response" == *'"state":"ready"'* ]] -} - -wait_for_smoke() { - local deadline - - if "$dry_run"; then - log "DRY-RUN: would smoke check $PUBLIC_URL and $TILE_BATCH_URL" - return 0 - fi - - deadline=$((SECONDS + DEPLOY_SMOKE_TIMEOUT_SECONDS)) - until smoke_get "$PUBLIC_URL" && smoke_tiles; do - if [ "$SECONDS" -ge "$deadline" ]; then - fail "smoke checks failed for $PUBLIC_URL and $TILE_BATCH_URL" - fi - - sleep 3 - done - - log "smoke checks passed" -} - -rollback() { - local rollback_image - - if [ "$deployment_started" != "true" ] || [ -z "$rollback_tag" ] || [ "$rollback_done" = "true" ]; then - return 0 - fi - - rollback_done=true - rollback_in_progress=true - printf '[deploy:%s] rolling back to %s\n' "$APP_NAME" "$rollback_tag" >&2 - rollback_image="$("$container_cli" image inspect "$rollback_tag" --format '{{.Id}}' 2>/dev/null || true)" - "$container_cli" tag "$rollback_tag" "$latest_tag" || true - if container_running; then - restart_service || true - else - printf '[deploy:%s] waiting for %s to recover with rollback image\n' "$APP_NAME" "$SERVICE_NAME" >&2 - fi - if [ -n "$rollback_image" ] && wait_for_container_image "$rollback_image" "rollback image"; then - printf '[deploy:%s] rollback image is running\n' "$APP_NAME" >&2 - else - printf '[deploy:%s] ERROR: rollback image did not become healthy\n' "$APP_NAME" >&2 - fi - rollback_in_progress=false -} - -on_error() { - local status=$? - rollback - exit "$status" -} - -trap on_error ERR - -require_main_push -select_container_cli -validate_restart_strategy - -log "using container CLI: $container_cli" -log "target image: $latest_tag" -log "target service: $SERVICE_NAME" - -initialize_submodules -tag_existing_latest_for_rollback -build_image -deployment_started=true -restart_service -wait_for_container_restart -wait_for_smoke - -log "deployment finished" diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..fbb133d --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,44 @@ +import { renderToString } from "react-dom/server"; +import { describe, expect, test } from "vitest"; +import { AppStateView } from "./App"; + +describe("React app dashboard state view", () => { + test("renders loading dashboard state", () => { + const html = renderToString( + , + ); + + expect(html).toContain("Loading Dashboard"); + expect(html).toContain("Fetching active model"); + }); + + test("renders an accessible theme toggle with the active theme", () => { + const html = renderToString( + undefined} + />, + ); + + expect(html).toContain('data-ui-theme-toggle="true"'); + expect(html).toContain('data-ui-theme-current="dark"'); + expect(html).toContain('aria-label="Light theme"'); + expect(html).toContain('aria-pressed="false"'); + expect(html).toContain("Theme"); + expect(html).toContain("Dark"); + expect(html).toContain("Light"); + }); +}); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..99cdc5e --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,183 @@ +import { useEffect, useState } from "react"; +import type { DashboardRuntimeState } from "$lib/server/dashboard"; +import { + DashboardFrame, + SystemState, + ThemeToggle, + dashboardDocumentToUiDashboard, + persistUiTheme, + resolveInitialUiTheme, + type UiTheme, + type UiSeverity, +} from "$lib/ui"; + +const loadingDashboardState: DashboardRuntimeState = { + state: "loading", + title: "Loading Dashboard", + subtitle: "Fetching active model", + message: "Waiting for the active dashboard document.", +}; + +export function AppStateView({ + dashboard, + onThemeChange, + theme, +}: { + dashboard: DashboardRuntimeState; + onThemeChange?: (theme: UiTheme) => void; + theme?: UiTheme; +}) { + const themeToggle = + theme && onThemeChange ? ( + + ) : null; + + if (dashboard.state === "ready") { + return ( + + ); + } + + const detail = `${dashboard.subtitle}: ${dashboard.message}`; + const errors = dashboard.state === "invalid" ? dashboard.errors : []; + + return ( +
+ {themeToggle ? ( +
{themeToggle}
+ ) : null} + + {errors.length ? ( +
    + {errors.map((error) => ( +
  • {error}
  • + ))} +
+ ) : null} +
+ ); +} + +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); + const [theme, setTheme] = useState(() => { + if (typeof window === "undefined") return "dark"; + + return resolveInitialUiTheme(getThemeStorage()); + }); + + 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(() => { + document.documentElement.dataset.uiTheme = theme; + persistUiTheme(theme, getThemeStorage()); + }, [theme]); + + useEffect(() => { + let cancelled = false; + let refreshTimer: number | undefined; + + async function loadDashboard() { + const response = await fetch("/api/dashboard"); + const nextDashboard = (await response.json()) as DashboardRuntimeState; + + if (cancelled) return; + setDashboard(nextDashboard); + + if (refreshTimer) { + window.clearInterval(refreshTimer); + refreshTimer = undefined; + } + + const refreshIntervalSeconds = + nextDashboard.state === "ready" + ? nextDashboard.document.metadata.refreshIntervalSeconds + : undefined; + + if (refreshIntervalSeconds) { + refreshTimer = window.setInterval( + () => void loadDashboard(), + refreshIntervalSeconds * 1000, + ); + } + } + + void loadDashboard(); + + return () => { + cancelled = true; + if (refreshTimer) window.clearInterval(refreshTimer); + }; + }, []); + + return ( + + ); +} + +function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity { + if (state === "invalid") return "danger"; + if (state === "loading") return "loading"; + return "stale"; +} + +function stateIcon(state: DashboardRuntimeState["state"]): string { + if (state === "invalid") return "mdi:file-alert-outline"; + if (state === "loading") return "mdi:progress-clock"; + return "mdi:tray"; +} + +function getThemeStorage(): Storage | undefined { + try { + return window.localStorage; + } catch { + return undefined; + } +} diff --git a/apps/web/src/app.css b/src/app.css similarity index 97% rename from apps/web/src/app.css rename to src/app.css index eeaabcb..0345394 100644 --- a/apps/web/src/app.css +++ b/src/app.css @@ -1,7 +1,9 @@ @import "tailwindcss"; -@import "@dimensionlab/ui/styles.css"; +@import "./lib/ui/tokens.css"; +@import "./lib/ui/components/styles.css"; @import "tw-animate-css"; @import "shadcn/tailwind.css"; +@import "@fontsource-variable/geist"; @custom-variant dark (&:is(.dark *)); diff --git a/src/lib/components/ui/alert.tsx b/src/lib/components/ui/alert.tsx new file mode 100644 index 0000000..fc34841 --- /dev/null +++ b/src/lib/components/ui/alert.tsx @@ -0,0 +1,76 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "$lib/utils" + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className + )} + {...props} + /> + ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/src/lib/components/ui/badge.tsx b/src/lib/components/ui/badge.tsx new file mode 100644 index 0000000..a3d91d7 --- /dev/null +++ b/src/lib/components/ui/badge.tsx @@ -0,0 +1,49 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "$lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/lib/components/ui/button.tsx b/src/lib/components/ui/button.tsx new file mode 100644 index 0000000..8dda5f2 --- /dev/null +++ b/src/lib/components/ui/button.tsx @@ -0,0 +1,67 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "$lib/utils" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/src/lib/components/ui/card.tsx b/src/lib/components/ui/card.tsx new file mode 100644 index 0000000..bcb9e07 --- /dev/null +++ b/src/lib/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "$lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/lib/components/ui/progress.tsx b/src/lib/components/ui/progress.tsx new file mode 100644 index 0000000..65d0a6b --- /dev/null +++ b/src/lib/components/ui/progress.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import { Progress as ProgressPrimitive } from "radix-ui" + +import { cn } from "$lib/utils" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Progress } diff --git a/src/lib/components/ui/separator.tsx b/src/lib/components/ui/separator.tsx new file mode 100644 index 0000000..84e3c64 --- /dev/null +++ b/src/lib/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import { Separator as SeparatorPrimitive } from "radix-ui" + +import { cn } from "$lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/src/lib/components/ui/skeleton.tsx b/src/lib/components/ui/skeleton.tsx new file mode 100644 index 0000000..61466a8 --- /dev/null +++ b/src/lib/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "$lib/utils" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts b/src/lib/model/fixtures/dimensionlab.test.ts similarity index 99% rename from apps/web/src/lib/dashboard-seed/dimensionlab.test.ts rename to src/lib/model/fixtures/dimensionlab.test.ts index 1a97c3b..38b2ac6 100644 --- a/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts +++ b/src/lib/model/fixtures/dimensionlab.test.ts @@ -7,7 +7,7 @@ import type { DatasourceReference, ServiceEntry, TelemetryCard, -} from "@dimensionlab/dashboard-model"; +} from "../schema"; describe("Dimension Lab dashboard seed", () => { test("defines the primary first-screen sections as model data", () => { @@ -128,6 +128,7 @@ const verifiedSeedIconIds = new Set([ "mdi:pulse", "mdi:robot-outline", "mdi:router-network", + "mdi:text-box-search", "mdi:thermometer", "mdi:web", "mdi:weather-sunny", diff --git a/apps/web/src/lib/dashboard-seed/dimensionlab.ts b/src/lib/model/fixtures/dimensionlab.ts similarity index 98% rename from apps/web/src/lib/dashboard-seed/dimensionlab.ts rename to src/lib/model/fixtures/dimensionlab.ts index 59162e6..c2b4808 100644 --- a/apps/web/src/lib/dashboard-seed/dimensionlab.ts +++ b/src/lib/model/fixtures/dimensionlab.ts @@ -7,7 +7,7 @@ import { type ServiceGroup, type Severity, type TelemetryCard, -} from "@dimensionlab/dashboard-model"; +} from "../schema"; type NumericValueKind = "percent" | "bytes" | "temperature" | "latency" | "number"; @@ -431,6 +431,14 @@ export const dimensionLabDashboardFixture: DashboardDocument = { href: "https://models.dimensionlab.net", datasource: uptimeMonitor(7), }), + service({ + id: "prompt-registry", + label: "Prompt Registry", + description: "Shared prompts, traces, evals", + icon: "mdi:text-box-search", + href: "https://prompts.dimensionlab.net", + datasource: uptimeMonitor(20), + }), ]), group("systems", "Systems", [ service({ diff --git a/packages/dashboard-model/src/fixtures/generic.ts b/src/lib/model/fixtures/generic.ts similarity index 100% rename from packages/dashboard-model/src/fixtures/generic.ts rename to src/lib/model/fixtures/generic.ts diff --git a/src/lib/model/fixtures/index.ts b/src/lib/model/fixtures/index.ts new file mode 100644 index 0000000..a04278d --- /dev/null +++ b/src/lib/model/fixtures/index.ts @@ -0,0 +1,2 @@ +export { dimensionLabDashboardFixture } from "./dimensionlab"; +export { genericDashboardFixture } from "./generic"; diff --git a/packages/dashboard-model/src/index.ts b/src/lib/model/index.ts similarity index 100% rename from packages/dashboard-model/src/index.ts rename to src/lib/model/index.ts diff --git a/packages/dashboard-model/src/schema.test.ts b/src/lib/model/schema.test.ts similarity index 96% rename from packages/dashboard-model/src/schema.test.ts rename to src/lib/model/schema.test.ts index 9975f87..7eeed74 100644 --- a/packages/dashboard-model/src/schema.test.ts +++ b/src/lib/model/schema.test.ts @@ -5,6 +5,7 @@ import { validateDashboardDocument, } from "."; import { + dimensionLabDashboardFixture, genericDashboardFixture, } from "./fixtures"; @@ -18,6 +19,12 @@ describe("dashboard model validation", () => { } }); + it("accepts the Dimension Lab dashboard fixture", () => { + const result = validateDashboardDocument(dimensionLabDashboardFixture); + + expect(result.valid).toBe(true); + }); + it("rejects documents with an unsupported schema version", () => { const invalid = { ...genericDashboardFixture, diff --git a/packages/dashboard-model/src/schema.ts b/src/lib/model/schema.ts similarity index 100% rename from packages/dashboard-model/src/schema.ts rename to src/lib/model/schema.ts diff --git a/packages/dashboard-model/src/validation.ts b/src/lib/model/validation.ts similarity index 100% rename from packages/dashboard-model/src/validation.ts rename to src/lib/model/validation.ts diff --git a/apps/web/src/lib/presentation-boundary.test.ts b/src/lib/presentation-boundary.test.ts similarity index 61% rename from apps/web/src/lib/presentation-boundary.test.ts rename to src/lib/presentation-boundary.test.ts index 8a62205..8e29ece 100644 --- a/apps/web/src/lib/presentation-boundary.test.ts +++ b/src/lib/presentation-boundary.test.ts @@ -2,17 +2,9 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; -const appRoot = process.cwd().endsWith(`${join("apps", "web")}`) - ? process.cwd() - : join(process.cwd(), "apps", "web"); -const repoRoot = existsSync(join(process.cwd(), "turbo.json")) - ? process.cwd() - : join(appRoot, "..", ".."); const presentationRoots = [ - join(repoRoot, "packages", "ui", "src"), - join(appRoot, "src", "App.tsx"), - join(appRoot, "src", "app.css"), - join(appRoot, "src", "lib", "ui-adapter"), + join(process.cwd(), "src", "lib", "ui"), + join(process.cwd(), "src", "routes"), ]; const forbiddenTerms = [ @@ -34,9 +26,7 @@ const forbiddenTerms = [ describe("presentation content boundary", () => { test("keeps environment-specific content out of route and UI implementation", () => { - const source = withoutInternalPackageScope( - presentationRoots.map(readPresentationSource).join("\n").toLowerCase(), - ); + const source = presentationRoots.map(readPresentationSource).join("\n").toLowerCase(); expect(forbiddenTerms.filter((term) => source.includes(term))).toEqual([]); }); @@ -44,7 +34,7 @@ describe("presentation content boundary", () => { test("does not keep legacy presentation component files in the React runtime", () => { const legacyExtension = [".sve", "lte"].join(""); - expect(findFiles(join(appRoot, "src"), legacyExtension)).toEqual([]); + expect(findFiles(join(process.cwd(), "src"), legacyExtension)).toEqual([]); }); }); @@ -54,9 +44,7 @@ function readPresentationSource(path: string): string { const stats = statSync(path); if (stats.isFile()) { if (path.endsWith(".test.ts")) return ""; - if (path.includes(`${join("packages", "ui", "src", "stories")}${"/"}`)) { - return ""; - } + if (path.includes(`${join("src", "lib", "ui", "stories")}${"/"}`)) return ""; if (!/\.(tsx|ts|js|mjs|css|json)$/.test(path)) return ""; return readFileSync(path, "utf8"); } @@ -72,9 +60,3 @@ function findFiles(path: string, extension: string): string[] { return readdirSync(path).flatMap((entry) => findFiles(join(path, entry), extension)); } - -function withoutInternalPackageScope(source: string): string { - return source - .replaceAll("@dimensionlab/dashboard-model", "@internal/dashboard-model") - .replaceAll("@dimensionlab/ui", "@internal/ui"); -} diff --git a/apps/web/src/lib/server/agent-config/agent-config.test.ts b/src/lib/server/agent-config/agent-config.test.ts similarity index 99% rename from apps/web/src/lib/server/agent-config/agent-config.test.ts rename to src/lib/server/agent-config/agent-config.test.ts index cdc60cf..87449e8 100644 --- a/apps/web/src/lib/server/agent-config/agent-config.test.ts +++ b/src/lib/server/agent-config/agent-config.test.ts @@ -3,8 +3,8 @@ import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; -import type { DashboardDocument } from "@dimensionlab/dashboard-model"; -import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; +import type { DashboardDocument } from "$lib/model"; +import { genericDashboardFixture } from "$lib/model/fixtures/generic"; import { createDashboardStore, type DashboardStore } from "$lib/server/db/dashboard-store"; import { AgentConfigAuthorizationError, diff --git a/apps/web/src/lib/server/agent-config/index.ts b/src/lib/server/agent-config/index.ts similarity index 99% rename from apps/web/src/lib/server/agent-config/index.ts rename to src/lib/server/agent-config/index.ts index ab3d0ae..ffa7dd8 100644 --- a/apps/web/src/lib/server/agent-config/index.ts +++ b/src/lib/server/agent-config/index.ts @@ -16,7 +16,7 @@ import { type ServiceGroup, type StatusItem, type TelemetryCard, -} from "@dimensionlab/dashboard-model"; +} from "$lib/model"; import { createDashboardStore, DashboardRevisionNotFoundError, diff --git a/apps/web/src/lib/server/dashboard.test.ts b/src/lib/server/dashboard.test.ts similarity index 89% rename from apps/web/src/lib/server/dashboard.test.ts rename to src/lib/server/dashboard.test.ts index 5d93422..b15fa6c 100644 --- a/apps/web/src/lib/server/dashboard.test.ts +++ b/src/lib/server/dashboard.test.ts @@ -3,8 +3,8 @@ import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; -import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; -import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; +import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab"; +import { genericDashboardFixture } from "$lib/model/fixtures/generic"; import { createDashboardStore, type DashboardStore } from "./db/dashboard-store"; import { loadDashboardRuntime } from "./dashboard"; @@ -66,6 +66,9 @@ describe("dashboard runtime loader", () => { expect(runtime.document.statusStrips[0]?.items.map((item) => item.id)).toContain( "auto-refresh", ); + expect(runtime.document.serviceGroups.flatMap((group) => group.services).map((service) => service.id)).toContain( + "prompt-registry", + ); expect(store.listRevisions()).toHaveLength(2); expect(store.getActiveDashboard()?.revision.actor).toBe("initial-seed"); }); @@ -135,5 +138,13 @@ function olderDimensionLabSeed() { ...strip, items: strip.items.filter((item) => item.id !== "auto-refresh"), })); + document.serviceGroups = document.serviceGroups.map((group) => + group.id === "ai-automation" + ? { + ...group, + services: group.services.filter((service) => service.id !== "prompt-registry"), + } + : group, + ); return document; } diff --git a/apps/web/src/lib/server/dashboard.ts b/src/lib/server/dashboard.ts similarity index 94% rename from apps/web/src/lib/server/dashboard.ts rename to src/lib/server/dashboard.ts index 33afa3a..7821ee0 100644 --- a/apps/web/src/lib/server/dashboard.ts +++ b/src/lib/server/dashboard.ts @@ -1,5 +1,5 @@ -import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; -import type { DashboardDocument } from "@dimensionlab/dashboard-model"; +import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab"; +import type { DashboardDocument } from "$lib/model"; import { createDashboardStore, DashboardPersistenceValidationError, @@ -18,9 +18,6 @@ export interface DashboardRuntimeReady { document: DashboardDocument; schemaVersion: string; currentRevisionId: string; - liveDatasourceHydration?: { - enabled: boolean; - }; } export interface DashboardRuntimeEmpty { diff --git a/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts b/src/lib/server/datasources/dashboard-datasources.test.ts similarity index 73% rename from apps/web/src/lib/server/datasources/dashboard-datasources.test.ts rename to src/lib/server/datasources/dashboard-datasources.test.ts index d24b48b..19082a2 100644 --- a/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts +++ b/src/lib/server/datasources/dashboard-datasources.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test, vi } from "vitest"; import { DASHBOARD_SCHEMA_VERSION, type DashboardDocument, -} from "@dimensionlab/dashboard-model"; -import { resolveDashboardDatasources, resolveDashboardTile } from "."; +} from "$lib/model"; +import { resolveDashboardDatasources } from "."; describe("dashboard datasource resolution", () => { test("hydrates telemetry, service health, weather, and summary data from live adapters", async () => { @@ -124,100 +124,6 @@ describe("dashboard datasource resolution", () => { expect(resolved).not.toBe(testDocument); expect(testDocument.telemetry[0].value.value).toBe(1); }); - - test("shares service health snapshots across aggregate tile hydration", async () => { - let resolveFetch: ((response: Response) => void) | undefined; - const fetch = vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url !== "https://service.example/health") { - throw new Error(`Unhandled test request: ${url}`); - } - - return new Promise((resolve) => { - resolveFetch = resolve; - }); - }); - const document = testDashboard(); - - const moduleTile = resolveDashboardTile( - document, - { kind: "module", id: "runtime-health-summary" }, - { fetch }, - ); - const statusTile = resolveDashboardTile( - document, - { kind: "status", stripId: "footer", id: "system-status" }, - { fetch }, - ); - - await Promise.resolve(); - expect(fetch).toHaveBeenCalledTimes(1); - - resolveFetch?.(jsonResponse({ status: "UP", ping: 42 })); - expect(await moduleTile).toMatchObject({ - state: "ready", - item: { - id: "runtime-health-summary", - severity: "ok", - value: "all systems operational", - }, - }); - expect(await statusTile).toMatchObject({ - state: "ready", - item: { - id: "system-status", - severity: "ok", - value: "All systems operational", - }, - }); - }); - - test("isolates service health snapshots by datasource fetch context", async () => { - const firstFetch = vi.fn(async () => - jsonResponse({ - status: "UP", - ping: 42, - }) - ); - const secondFetch = vi.fn(async () => - jsonResponse({ - status: "DOWN", - ping: 0, - }) - ); - const document = testDashboard(); - document.serviceGroups[0].services[0] = { - ...document.serviceGroups[0].services[0], - id: "api-isolated", - datasource: { - type: "external", - adapter: "http-status", - reference: "GET https://service.example/isolated", - }, - }; - - await resolveDashboardTile( - document, - { kind: "status", stripId: "footer", id: "system-status" }, - { fetch: firstFetch }, - ); - const second = await resolveDashboardTile( - document, - { kind: "status", stripId: "footer", id: "system-status" }, - { fetch: secondFetch }, - ); - - expect(firstFetch).toHaveBeenCalledTimes(1); - expect(secondFetch).toHaveBeenCalledTimes(1); - expect(second).toMatchObject({ - state: "ready", - item: { - id: "system-status", - severity: "danger", - value: "1 service down", - }, - }); - }); }); const testDocument = testDashboard(); diff --git a/apps/web/src/lib/server/datasources/index.ts b/src/lib/server/datasources/index.ts similarity index 73% rename from apps/web/src/lib/server/datasources/index.ts rename to src/lib/server/datasources/index.ts index 62df13a..804fbb5 100644 --- a/apps/web/src/lib/server/datasources/index.ts +++ b/src/lib/server/datasources/index.ts @@ -8,40 +8,10 @@ import type { StatusItem, StatusStrip, TelemetryCard, -} from "@dimensionlab/dashboard-model"; - -export type DashboardTileReference = - | { kind: "telemetry"; id: string } - | { kind: "service"; groupId: string; id: string } - | { kind: "module"; id: string } - | { kind: "status"; stripId: string; id: string }; - -export type DashboardTileItem = - | DashboardModule - | ServiceEntry - | StatusItem - | TelemetryCard; - -export type DashboardTileResolution = - | { - state: "ready"; - tile: DashboardTileReference; - item: DashboardTileItem; - } - | { - state: "not_found"; - tile: DashboardTileReference; - message: string; - } - | { - state: "disabled"; - tile: DashboardTileReference; - message: string; - }; +} from "$lib/model"; export interface DatasourceResolutionOptions { fetch?: DatasourceFetch; - now?: () => number; prometheusBaseUrl?: string; prometheusRangeSeconds?: number; prometheusStepSeconds?: number; @@ -76,90 +46,16 @@ export async function resolveDashboardDatasources( }; } -export async function resolveDashboardTile( - document: DashboardDocument, - tile: DashboardTileReference, - options: DatasourceResolutionOptions = {}, -): Promise { - const context = datasourceContext(options); - - if (tile.kind === "telemetry") { - const card = document.telemetry.find((item) => item.id === tile.id); - if (!card) return missingTile(tile); - - return { - state: "ready", - tile, - item: await resolveTelemetryCard(card, context), - }; - } - - if (tile.kind === "service") { - const service = document.serviceGroups - .find((group) => group.id === tile.groupId) - ?.services.find((item) => item.id === tile.id); - if (!service) return missingTile(tile); - - return { - state: "ready", - tile, - item: await resolveService(service, context), - }; - } - - if (tile.kind === "module") { - const module = document.modules?.find((item) => item.id === tile.id); - if (!module) return missingTile(tile); - - const item = module.id === "runtime-health-summary" - ? runtimeHealthSummary( - module, - await serviceGroupsSnapshot(document, context), - ) - : await resolveModule(module, context); - - return { state: "ready", tile, item }; - } - - const strip = document.statusStrips.find((item) => item.id === tile.stripId); - const statusItem = strip?.items.find((item) => item.id === tile.id); - if (!strip || !statusItem) return missingTile(tile); - - return { - state: "ready", - tile, - item: await resolveStatusTile( - statusItem, - document.metadata.refreshIntervalSeconds, - document, - context, - ), - }; -} - interface DatasourceContext { fetch: DatasourceFetch; - fetchIdentity: number; - now: () => number; prometheusBaseUrl: string; prometheusRangeSeconds: number; prometheusStepSeconds: number; requestTimeoutMs: number; - serviceGroupsSnapshot?: Promise; } type DatasourceFetch = (input: string, init?: RequestInit) => Promise; -interface ServiceGroupsSnapshotEntry { - expiresAt: number; - snapshot: Promise; -} - -const serviceGroupsSnapshotTtlMs = 30_000; -const serviceGroupsSnapshotCache = new Map(); -const datasourceFetchIdentities = new WeakMap(); -let nextDatasourceFetchIdentity = 1; - interface PrometheusVectorResult { metric?: Record; value?: [number, string]; @@ -171,12 +67,8 @@ interface PrometheusMatrixResult { } function datasourceContext(options: DatasourceResolutionOptions): DatasourceContext { - const fetch = options.fetch || globalThis.fetch; - return { - fetch, - fetchIdentity: datasourceFetchIdentity(fetch), - now: options.now || Date.now, + fetch: options.fetch || globalThis.fetch, prometheusBaseUrl: options.prometheusBaseUrl || process.env.PROMETHEUS_BASE_URL || @@ -187,68 +79,6 @@ function datasourceContext(options: DatasourceResolutionOptions): DatasourceCont }; } -function datasourceFetchIdentity(fetch: DatasourceFetch): number { - const existing = datasourceFetchIdentities.get(fetch); - if (existing) return existing; - - const next = nextDatasourceFetchIdentity; - nextDatasourceFetchIdentity += 1; - datasourceFetchIdentities.set(fetch, next); - return next; -} - -function serviceGroupsSnapshot( - document: DashboardDocument, - context: DatasourceContext, -): Promise { - if (context.serviceGroupsSnapshot) return context.serviceGroupsSnapshot; - - const key = serviceGroupsSnapshotKey(document, context); - const now = context.now(); - const cached = serviceGroupsSnapshotCache.get(key); - if (cached && cached.expiresAt > now) { - context.serviceGroupsSnapshot = cached.snapshot; - return cached.snapshot; - } - - const snapshot = Promise.all( - document.serviceGroups.map((group) => resolveServiceGroup(group, context)), - ); - context.serviceGroupsSnapshot = snapshot; - serviceGroupsSnapshotCache.set(key, { - expiresAt: now + serviceGroupsSnapshotTtlMs, - snapshot, - }); - snapshot.catch(() => { - if (serviceGroupsSnapshotCache.get(key)?.snapshot === snapshot) { - serviceGroupsSnapshotCache.delete(key); - } - }); - return snapshot; -} - -function serviceGroupsSnapshotKey( - document: DashboardDocument, - context: DatasourceContext, -): string { - return JSON.stringify( - { - fetchIdentity: context.fetchIdentity, - prometheusBaseUrl: context.prometheusBaseUrl, - prometheusRangeSeconds: context.prometheusRangeSeconds, - prometheusStepSeconds: context.prometheusStepSeconds, - requestTimeoutMs: context.requestTimeoutMs, - serviceGroups: document.serviceGroups.map((group) => ({ - id: group.id, - services: group.services.map((service) => ({ - datasource: service.datasource, - id: service.id, - })), - })), - }, - ); -} - async function resolveTelemetryCard( card: TelemetryCard, context: DatasourceContext, @@ -560,66 +390,6 @@ function resolveStatusItem( return structuredClone(item); } -async function resolveStatusTile( - item: StatusItem, - refreshIntervalSeconds: number | undefined, - document: DashboardDocument, - context: DatasourceContext, -): Promise { - if (item.id === "system-status") { - const resolvedGroups = await serviceGroupsSnapshot(document, context); - const health = serviceHealthSummary(resolvedGroups); - return { - ...structuredClone(item), - value: health.value, - severity: health.severity, - }; - } - - if (item.id === "last-sync") { - return { - ...structuredClone(item), - value: "just now", - severity: "ok", - }; - } - - if (item.id === "uptime") { - const uptime = await prometheusScalar( - 'time() - node_boot_time_seconds{job="node",host="linux-infra"}', - context, - ).catch(() => null); - return uptime === null - ? structuredClone(item) - : { - ...structuredClone(item), - value: formatDuration(uptime), - severity: "ok", - }; - } - - if (item.id === "load-avg") { - const loadAverage = await prometheusLoadAverage(context).catch(() => null); - return loadAverage - ? { - ...structuredClone(item), - value: loadAverage, - severity: "neutral", - } - : structuredClone(item); - } - - if (item.id === "auto-refresh" && refreshIntervalSeconds) { - return { - ...structuredClone(item), - value: `${refreshIntervalSeconds}s`, - severity: "neutral", - }; - } - - return structuredClone(item); -} - function serviceHealthSummary(serviceGroups: ServiceGroup[]): { severity: Severity; value: string; @@ -903,17 +673,3 @@ function formatDuration(totalSeconds: number): string { const minutes = Math.floor((seconds % 3_600) / 60); return `${days}d ${hours}h ${minutes}m`; } - -function missingTile(tile: DashboardTileReference): DashboardTileResolution { - return { - state: "not_found", - tile, - message: `Dashboard tile not found: ${tileKey(tile)}`, - }; -} - -function tileKey(tile: DashboardTileReference): string { - if (tile.kind === "status") return `${tile.kind}:${tile.stripId}:${tile.id}`; - if (tile.kind === "service") return `${tile.kind}:${tile.groupId}:${tile.id}`; - return `${tile.kind}:${tile.id}`; -} diff --git a/apps/web/src/lib/server/db/connection.ts b/src/lib/server/db/connection.ts similarity index 100% rename from apps/web/src/lib/server/db/connection.ts rename to src/lib/server/db/connection.ts diff --git a/apps/web/src/lib/server/db/dashboard-store.test.ts b/src/lib/server/db/dashboard-store.test.ts similarity index 97% rename from apps/web/src/lib/server/db/dashboard-store.test.ts rename to src/lib/server/db/dashboard-store.test.ts index c2717f5..ecb80f3 100644 --- a/apps/web/src/lib/server/db/dashboard-store.test.ts +++ b/src/lib/server/db/dashboard-store.test.ts @@ -3,8 +3,8 @@ import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; -import type { DashboardDocument } from "@dimensionlab/dashboard-model"; +import { genericDashboardFixture } from "$lib/model/fixtures/generic"; +import type { DashboardDocument } from "$lib/model"; import { DashboardPersistenceValidationError, createDashboardStore, diff --git a/apps/web/src/lib/server/db/dashboard-store.ts b/src/lib/server/db/dashboard-store.ts similarity index 99% rename from apps/web/src/lib/server/db/dashboard-store.ts rename to src/lib/server/db/dashboard-store.ts index 3928206..92c7a73 100644 --- a/apps/web/src/lib/server/db/dashboard-store.ts +++ b/src/lib/server/db/dashboard-store.ts @@ -3,7 +3,7 @@ import { desc, eq } from "drizzle-orm"; import { type DashboardDocument, type DashboardValidationFailure, -} from "@dimensionlab/dashboard-model"; +} from "$lib/model"; import { type DashboardDatabaseConnection, openDashboardDatabase, diff --git a/apps/web/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts similarity index 100% rename from apps/web/src/lib/server/db/migrations.ts rename to src/lib/server/db/migrations.ts diff --git a/apps/web/src/lib/server/db/model-migrations.test.ts b/src/lib/server/db/model-migrations.test.ts similarity index 87% rename from apps/web/src/lib/server/db/model-migrations.test.ts rename to src/lib/server/db/model-migrations.test.ts index 5c863a0..eb16b8f 100644 --- a/apps/web/src/lib/server/db/model-migrations.test.ts +++ b/src/lib/server/db/model-migrations.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { DASHBOARD_SCHEMA_VERSION } from "@dimensionlab/dashboard-model"; -import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; +import { DASHBOARD_SCHEMA_VERSION } from "$lib/model"; +import { genericDashboardFixture } from "$lib/model/fixtures/generic"; import { UnsupportedDashboardModelVersionError, migrateDashboardDocumentForPersistence, diff --git a/apps/web/src/lib/server/db/model-migrations.ts b/src/lib/server/db/model-migrations.ts similarity index 97% rename from apps/web/src/lib/server/db/model-migrations.ts rename to src/lib/server/db/model-migrations.ts index 72e9c47..92550e6 100644 --- a/apps/web/src/lib/server/db/model-migrations.ts +++ b/src/lib/server/db/model-migrations.ts @@ -3,7 +3,7 @@ import { validateDashboardDocument, type DashboardDocument, type DashboardValidationFailure, -} from "@dimensionlab/dashboard-model"; +} from "$lib/model"; export interface DashboardModelMigrationSuccess { valid: true; diff --git a/apps/web/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts similarity index 94% rename from apps/web/src/lib/server/db/schema.ts rename to src/lib/server/db/schema.ts index a3a7206..c148035 100644 --- a/apps/web/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,4 +1,4 @@ -import type { DashboardDocument } from "@dimensionlab/dashboard-model"; +import type { DashboardDocument } from "$lib/model"; import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const dashboardDocuments = sqliteTable("dashboard_documents", { diff --git a/apps/web/src/lib/testing/external-api-mocks.test.ts b/src/lib/testing/external-api-mocks.test.ts similarity index 100% rename from apps/web/src/lib/testing/external-api-mocks.test.ts rename to src/lib/testing/external-api-mocks.test.ts diff --git a/apps/web/src/lib/testing/external-api-mocks.ts b/src/lib/testing/external-api-mocks.ts similarity index 100% rename from apps/web/src/lib/testing/external-api-mocks.ts rename to src/lib/testing/external-api-mocks.ts diff --git a/src/lib/ui/components/Badge.tsx b/src/lib/ui/components/Badge.tsx new file mode 100644 index 0000000..a21ade8 --- /dev/null +++ b/src/lib/ui/components/Badge.tsx @@ -0,0 +1,11 @@ +import type { UiSeverity } from "../types"; +import { StatusBadge } from "./StatusBadge"; + +export interface BadgeProps { + label: string; + severity?: UiSeverity; +} + +export function Badge({ label, severity = "neutral" }: BadgeProps) { + return ; +} diff --git a/src/lib/ui/components/Button.tsx b/src/lib/ui/components/Button.tsx new file mode 100644 index 0000000..8ae3b4e --- /dev/null +++ b/src/lib/ui/components/Button.tsx @@ -0,0 +1,39 @@ +import type { ButtonHTMLAttributes } from "react"; +import { IconGlyph } from "./IconGlyph"; + +export interface ButtonProps + extends Omit, "type"> { + label: string; + variant?: "primary" | "secondary" | "danger" | "ghost"; + size?: "default" | "compact"; + icon?: string; + loading?: boolean; + type?: "button" | "submit" | "reset"; +} + +export function Button({ + label, + variant = "primary", + size = "default", + icon, + disabled = false, + loading = false, + type = "button", + className = "", + ...buttonProps +}: ButtonProps) { + return ( + + ); +} diff --git a/src/lib/ui/components/CornerBracketFrame.tsx b/src/lib/ui/components/CornerBracketFrame.tsx new file mode 100644 index 0000000..1dfd4f7 --- /dev/null +++ b/src/lib/ui/components/CornerBracketFrame.tsx @@ -0,0 +1,26 @@ +export interface CornerBracketFrameProps { + density?: "regular" | "tight"; + size?: "sm" | "md" | "lg"; + tone?: "neutral" | "accent" | "danger"; +} + +export function CornerBracketFrame({ + density = "regular", + size = "md", + tone = "neutral", +}: CornerBracketFrameProps) { + return ( + + ); +} diff --git a/src/lib/ui/components/DashboardFrame.tsx b/src/lib/ui/components/DashboardFrame.tsx new file mode 100644 index 0000000..cdf312a --- /dev/null +++ b/src/lib/ui/components/DashboardFrame.tsx @@ -0,0 +1,59 @@ +import { useId } from "react"; +import type { ReactNode } from "react"; +import type { UiDashboardPreview } from "../types"; +import { ModuleCard } from "./ModuleCard"; +import { ServicePanel } from "./ServicePanel"; +import { StatusStrip } from "./StatusStrip"; +import { TelemetryGrid } from "./TelemetryGrid"; + +export interface DashboardFrameProps { + actions?: ReactNode; + dashboard: UiDashboardPreview; + titleId?: string; +} + +export function DashboardFrame({ + actions, + dashboard, + titleId, +}: DashboardFrameProps) { + const generatedTitleId = useId(); + const resolvedTitleId = titleId || `${generatedTitleId}-title`; + + return ( +
+
+
+
+ {dashboard.eyebrow ?

{dashboard.eyebrow}

: null} +

{dashboard.title}

+ {dashboard.subtitle ? {dashboard.subtitle} : null} +
+ {actions ? ( +
{actions}
+ ) : null} +
+ {dashboard.modules.length ? ( +
+ {dashboard.modules.map((module) => ( + + ))} +
+ ) : null} +
+ + + +
+ {dashboard.serviceGroups.map((group) => ( + + ))} +
+ + +
+ ); +} diff --git a/src/lib/ui/components/DashboardHeader.tsx b/src/lib/ui/components/DashboardHeader.tsx new file mode 100644 index 0000000..68b4e11 --- /dev/null +++ b/src/lib/ui/components/DashboardHeader.tsx @@ -0,0 +1,30 @@ +import { useId } from "react"; +import type { UiModuleBlock } from "../types"; +import { ModuleCard } from "./ModuleCard"; + +export interface DashboardHeaderProps { + title: string; + subtitle?: string; + eyebrow?: string; + module?: UiModuleBlock; +} + +export function DashboardHeader({ + title, + subtitle, + eyebrow, + module, +}: DashboardHeaderProps) { + const titleId = useId(); + + return ( +
+
+ {eyebrow ?

{eyebrow}

: null} +

{title}

+ {subtitle ? {subtitle} : null} +
+ {module ? : null} +
+ ); +} diff --git a/src/lib/ui/components/DiagonalStripeField.tsx b/src/lib/ui/components/DiagonalStripeField.tsx new file mode 100644 index 0000000..b4c60f8 --- /dev/null +++ b/src/lib/ui/components/DiagonalStripeField.tsx @@ -0,0 +1,24 @@ +export interface DiagonalStripeFieldProps { + density?: "open" | "regular" | "tight"; + direction?: "forward" | "backward"; + size?: "sm" | "md" | "lg"; + tone?: "neutral" | "accent" | "warning" | "danger"; +} + +export function DiagonalStripeField({ + density = "regular", + direction = "forward", + size = "md", + tone = "accent", +}: DiagonalStripeFieldProps) { + return ( +