v0.1.0 — base app + stack from Flight-Ops, scificn-ui Mission Control interface #1
@@ -0,0 +1,19 @@
|
||||
# node_modules is intentionally IN the build context: the Dockerfile copies it straight
|
||||
# into the image (deps are installed by the CI job / dev host, which have working network;
|
||||
# the runner's dind daemon has broken outbound DNS for build containers).
|
||||
.next
|
||||
/db
|
||||
test-results
|
||||
*.tsbuildinfo
|
||||
.git
|
||||
.env
|
||||
.codenomad
|
||||
# e2e harness: the suite runs on the host / CI job, never inside the image
|
||||
# (@playwright/test is a devDependency of the runner, excluded from prod-deps)
|
||||
e2e
|
||||
playwright.config.ts
|
||||
# docs/deployment files never affect the app build — excluding them keeps
|
||||
# changelog-only commits from invalidating the COPY layer and re-running next build
|
||||
*.md
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
@@ -0,0 +1,229 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: build + unit + e2e
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# The runner provides a working docker CLI out of the box (full-dind) — no endpoint
|
||||
# resolution needed. Job and app containers share the daemon, so put them on one
|
||||
# user-defined network with a pinned subnet; the app gets a STATIC IP (172.39.0.10,
|
||||
# declared in docker-compose.ci.yml) because Chromium got ERR_SSL_PROTOCOL_ERROR via
|
||||
# the DNS alias while node-side HTTP to the same name worked — bypassing DNS removes
|
||||
# that variable entirely. A stale network with a different subnet is recreated.
|
||||
- name: Prepare e2e network
|
||||
run: |
|
||||
set -u
|
||||
# Interrupted runs skip the end-of-run cleanup, leaking a USED DB volume into later
|
||||
# runs (tests assume seeded state) — drop it so every run starts fresh.
|
||||
docker volume rm space-wanderer-ci_space-wanderer-db 2>/dev/null || true
|
||||
SUBNET=172.39.0.0/24
|
||||
if docker network inspect space-wanderer-ci-e2e >/dev/null 2>&1; then
|
||||
cur=$(docker network inspect space-wanderer-ci-e2e --format '{{(index .IPAM.Config 0).Subnet}}')
|
||||
if [ "$cur" != "$SUBNET" ]; then
|
||||
echo "stale space-wanderer-ci-e2e with subnet $cur — recreating with $SUBNET"
|
||||
docker network rm space-wanderer-ci-e2e || true
|
||||
docker network create --subnet "$SUBNET" space-wanderer-ci-e2e
|
||||
fi
|
||||
else
|
||||
docker network create --subnet "$SUBNET" space-wanderer-ci-e2e
|
||||
fi
|
||||
if ! docker network connect space-wanderer-ci-e2e "$(hostname)"; then
|
||||
echo "::error::failed to attach job container '$(hostname)' to space-wanderer-ci-e2e — the job must run in a container on the shared daemon (check runner config)" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker network inspect space-wanderer-ci-e2e --format 'containers on space-wanderer-ci-e2e: {{range .Containers}}{{.Name}} {{end}}'
|
||||
echo "E2E_BASE_URL=http://172.39.0.10:3000" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# bun drives installs (repo ships bun.lock, no package-lock.json); pinned to 1.4.2.
|
||||
# Retried: the runner's outbound network/DNS is flaky (transient EAI_AGAIN seen in CI).
|
||||
- name: Install bun
|
||||
run: |
|
||||
set -u
|
||||
ok=0
|
||||
for i in 1 2 3; do
|
||||
if npm i -g bun@1.4.2; then ok=1; break; fi
|
||||
echo "::warning::bun install attempt $i/3 failed (likely transient network) — retrying in 5s" >&2
|
||||
sleep 5
|
||||
done
|
||||
[ "$ok" = "1" ] || { echo "::error::bun install failed after 3 attempts" >&2; exit 1; }
|
||||
bun --version
|
||||
|
||||
# Job-level dep cache via the runner's built-in cache server. continue-on-error on
|
||||
# both steps: an unreachable/broken cache server must never break the run.
|
||||
- name: Restore deps cache
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
$HOME/.bun/install/cache
|
||||
key: ${{ runner.os }}-deps-bun-${{ hashFiles('bun.lock') }}
|
||||
|
||||
# Retried: the runner's outbound network/DNS is flaky (transient EAI_AGAIN seen in CI).
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
set -u
|
||||
ok=0
|
||||
for i in 1 2 3; do
|
||||
if bun install --frozen-lockfile; then ok=1; break; fi
|
||||
echo "::warning::dependency install attempt $i/3 failed (likely transient network) — retrying in 5s" >&2
|
||||
sleep 5
|
||||
done
|
||||
[ "$ok" = "1" ] || { echo "::error::dependency install failed after 3 attempts" >&2; exit 1; }
|
||||
|
||||
# No explicit save step: caches marked by the restore steps are uploaded automatically at job end.
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Unit tests
|
||||
run: npx vitest run
|
||||
|
||||
# Browsers run locally in CI (PW_REMOTE_BROWSER=0 below): the shared remote browser
|
||||
# server at 192.168.1.4 cannot reach a stack inside dind, and local chromium keeps
|
||||
# the job self-contained.
|
||||
# Browser binaries (~187 MiB) cached across runs: the runner's uplink is flaky and
|
||||
# cdn.playwright.dev downloads time out mid-transfer; once seeded, installs skip the download.
|
||||
- name: Restore browser cache
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: $HOME/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-pw-browsers-${{ hashFiles('package.json') }}
|
||||
|
||||
# Retried: cdn.playwright.dev is flaky from this runner — Playwright's internal 5-retry
|
||||
# window (~40-90s) is often not enough, but runs get through with a longer one. Progress
|
||||
# accumulates across attempts: playwright skips already-installed browsers.
|
||||
- name: Install browsers (local)
|
||||
run: |
|
||||
set -u
|
||||
ok=0
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if npx playwright install --with-deps chromium; then ok=1; break; fi
|
||||
echo "::warning::browser install attempt $i/6 failed (flaky cdn.playwright.dev) — retrying in 5s" >&2
|
||||
sleep 5
|
||||
done
|
||||
[ "$ok" = "1" ] || { echo "::error::browser install failed after 6 attempts" >&2; exit 1; }
|
||||
|
||||
# Cache the base image on the shared daemon up front: a single loud failure here
|
||||
# beats a 6-minute BuildKit "failed to solve" when the daemon's outbound net is down.
|
||||
- name: Pull e2e base image
|
||||
run: docker pull node:22-slim
|
||||
|
||||
# Pre-build the e2e image with retries (flaky runner network); the E2E step's own
|
||||
# build then becomes a fast no-op. Plain progress keeps BuildKit output visible.
|
||||
- name: Build e2e image (retries)
|
||||
run: |
|
||||
set -u
|
||||
ok=0
|
||||
for i in 1 2 3; do
|
||||
if docker compose -f docker-compose.ci.yml -p space-wanderer-ci build --progress=plain; then ok=1; break; fi
|
||||
echo "::warning::e2e image build attempt $i/3 failed (likely transient network) — retrying in 5s" >&2
|
||||
sleep 5
|
||||
done
|
||||
[ "$ok" = "1" ] || { echo "::error::e2e image build failed after 3 attempts" >&2; exit 1; }
|
||||
|
||||
# E2E against an isolated stack on the shared daemon — never the production instance.
|
||||
# docker-compose.ci.yml joins the space-wanderer-ci-e2e network prepared above; project name
|
||||
# space-wanderer-ci keeps it distinct from production. E2E_DEPLOY_CMD makes the config's
|
||||
# webServer block deploy + wait for readiness (http://app:3000 via service DNS);
|
||||
# E2E_COMPOSE_ARGS points state.spec.ts' mid-run `docker compose restart space-wanderer` at
|
||||
# the same file + project. E2E_BASE_URL comes from GITHUB_ENV (Prepare e2e network).
|
||||
- name: E2E (isolated stack in dind)
|
||||
env:
|
||||
PW_REMOTE_BROWSER: "0"
|
||||
# Build separately with plain progress so BuildKit step output is visible in the
|
||||
# job log (auto progress hides it). Playwright's webServer needs a long-running
|
||||
# command — `up -d` returns immediately and triggers "exited early"; `logs -f`
|
||||
# keeps it alive, streams app logs into CI output for debugging, and is killed with
|
||||
# the job (an orphaned `logs -f` dies on its own when cleanup removes the container).
|
||||
E2E_DEPLOY_CMD: "docker compose -f docker-compose.ci.yml -p space-wanderer-ci build --progress=plain && docker compose -f docker-compose.ci.yml -p space-wanderer-ci up -d && docker compose -f docker-compose.ci.yml -p space-wanderer-ci logs -f"
|
||||
E2E_COMPOSE_ARGS: "-f docker-compose.ci.yml -p space-wanderer-ci"
|
||||
# Chromium honors proxy env vars on Linux (node-side HTTP does not) — keep the
|
||||
# in-network app host out of any proxy path.
|
||||
NO_PROXY: app,localhost,127.0.0.1,.kkhost.pl
|
||||
no_proxy: app,localhost,127.0.0.1,.kkhost.pl
|
||||
# Machine-readable e2e results for the job-summary step below (Playwright's built-in
|
||||
# json reporter; without this env var it would dump to stdout). test-results/ is git-ignored.
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/e2e.json
|
||||
# Connectivity diagnostics now run post-deploy in e2e/ci-diagnostics.ts (globalSetup).
|
||||
# --reporter=list prints each test as it completes (name + ✓/× + duration) for progress
|
||||
# visibility in the job log; failure error context/snapshots are unchanged. The json
|
||||
# reporter adds a machine-readable report (PLAYWRIGHT_JSON_OUTPUT_FILE above) — no
|
||||
# execution behavior changes, reporters only observe.
|
||||
run: npx playwright test --reporter=list,json
|
||||
|
||||
# Appends e2e results to the job-summary panel — mirrors how unit tests get there
|
||||
# (vitest's built-in github-actions reporter auto-appends $GITHUB_STEP_SUMMARY in CI).
|
||||
# if: always() so a FAILED suite still gets summarized; non-fatal by design — a missing
|
||||
# or malformed report must never mask the real e2e exit code. The script is single-quoted
|
||||
# for bash, so it contains no apostrophes.
|
||||
- name: E2E job summary
|
||||
if: always()
|
||||
run: |
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
let report;
|
||||
try {
|
||||
report = JSON.parse(fs.readFileSync("test-results/e2e.json", "utf8"));
|
||||
} catch {
|
||||
process.exit(0); // e2e never ran (earlier step failed) or no report — nothing to summarize
|
||||
}
|
||||
let passed = 0, skipped = 0;
|
||||
const failed = [];
|
||||
const walk = (suite) => {
|
||||
for (const spec of suite.specs ?? []) {
|
||||
if (!spec.ok) failed.push(`${suite.title} › ${spec.title}`);
|
||||
else if ((spec.tests ?? []).length && spec.tests.every((t) => t.expectedStatus === "skipped")) skipped++;
|
||||
else passed++;
|
||||
}
|
||||
for (const sub of suite.suites ?? []) walk(sub);
|
||||
};
|
||||
for (const s of report.suites ?? []) walk(s);
|
||||
const lines = [
|
||||
"", "## E2E results", "",
|
||||
`- Passed: ${passed}`,
|
||||
`- Failed: ${failed.length}`,
|
||||
`- Skipped: ${skipped}`,
|
||||
];
|
||||
if (failed.length) {
|
||||
lines.push("", "### Failed specs");
|
||||
for (const f of failed) lines.push(`- \`${f}\``);
|
||||
}
|
||||
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
|
||||
if (summaryFile) fs.appendFileSync(summaryFile, lines.join("\n") + "\n");
|
||||
' || true
|
||||
|
||||
# The daemon is shared with production — never leave our stack or network behind.
|
||||
# -v drops the named DB volume so every run seeds fresh (no schema drift between PRs).
|
||||
- name: Cleanup e2e stack + network
|
||||
if: always()
|
||||
run: |
|
||||
docker compose -f docker-compose.ci.yml -p space-wanderer-ci down --remove-orphans -v || true
|
||||
docker network remove space-wanderer-ci-e2e 2>/dev/null || true
|
||||
|
||||
# The shared runner dind still accumulates between runs what the steps above can't reach:
|
||||
# dangling images/layers from the per-run space-wanderer-ci rebuilds, BuildKit cache, and job
|
||||
# containers leaked by interrupted runs. Prune them here (after stack + network cleanup).
|
||||
# Deliberately NO --volumes: the daemon is shared with production and named volumes may hold
|
||||
# other workflows' persistent data — this project's own space-wanderer-ci_space-wanderer-db volume is already
|
||||
# dropped by the steps above. Non-fatal so a prune failure can never mask or break the run.
|
||||
- name: Prune runner dind leftovers
|
||||
if: always()
|
||||
run: docker system prune -af || echo "::warning::dind prune failed — see logs"
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# env — never commit secrets
|
||||
.env
|
||||
|
||||
# deps
|
||||
node_modules/
|
||||
|
||||
# build output
|
||||
.next/
|
||||
dist/
|
||||
test-results/
|
||||
*.tsbuildinfo
|
||||
|
||||
# agent tooling state
|
||||
.codenomad/
|
||||
|
||||
# game state SQLite: lives on the host, volume-mounted into the container as
|
||||
# ./db → /app/db so it survives rebuilds — never tracked. Anchored to repo root:
|
||||
# an unanchored "db/" would also ignore lib/db/ (source).
|
||||
/db/
|
||||
@@ -0,0 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## v0.1.0 — 2026-09-28 15:47
|
||||
- Initial scaffold (stack proof): base stack copied/adapted from Flight-Ops (`../flight`) — Next.js 16 App Router + React 19 + TypeScript strict, bun package manager, Node 22 in CI/Docker.
|
||||
- Single-player only by design: NO auth/accounts/multiplayer anywhere (Flight-Ops's users/sessions/admin/leaderboard patterns deliberately not copied).
|
||||
- Frontend component layer: scificn-ui v0.1.0 integrated copy-paste (Radix + Tailwind v4, SCI-FI theme) — Button, Panel, Badge, Card, Alert in `components/ui/`; theme tokens in `app/globals.css` with a brightened `--text-muted` override (`#4A4A40` → `#6B6B5C`).
|
||||
- Interface base: the scificn-ui **Mission Control** showcase (https://www.scificn.dev/showcase/mission-control) rebuilt as the app's main UI — `app/page.tsx` renders `components/mission-control.tsx` (client dashboard: sticky header, stat-card row, 3-column grid of Panel/Tabs/StatusGrid/ProgressRing/BarChart/LineChart/Terminal panels, sticky footer), placeholder ship content only, no game logic; additional upstream components copied into `components/ui/` (Tabs, StatCard, Terminal, StatusGrid, ProgressRing, BarChart, LineChart) + `@radix-ui/react-tabs` dependency.
|
||||
- DB plumbing: SQLite via Drizzle ORM + sql.js (WASM) at `db/space-wanderer.db`, volume-mounted; empty schema placeholder — game tables land with the first game systems.
|
||||
- API pattern established: `/api/version` + OpenAPI 3.x spec generated in code (`lib/openapi.ts`) served at `/api/openapi`.
|
||||
- CI/e2e/docker pipeline: single "build + unit + e2e" job on the dind runner, isolated stack `space-wanderer-ci` with static IP 172.39.0.10 (fresh DB volume per run), Playwright e2e (`smoke.spec.ts`, `state.spec.ts`) CI-only against that stack; prod compose behind Traefik at `https://space-wanderer.dev.kkhost.pl` (not deployed yet).
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Two-stage build needing ZERO outbound network at image-build time:
|
||||
# (a) deps are installed by the CI job / dev host (`bun install`) and copied in via the
|
||||
# build context — the runner's dind daemon has broken outbound DNS for build containers,
|
||||
# so any RUN that fetches packages fails;
|
||||
# (b) base images must be glibc (node:22-slim), NOT alpine/musl: native modules (@next/swc-*)
|
||||
# are prebuilt for the platform that ran `bun install` — CI job and prod host are
|
||||
# x86_64 glibc linux. Building on another platform requires reinstalling node_modules there first.
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock ./
|
||||
COPY node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# No `output: 'standalone'`: the runtime stage keeps node_modules because sql.js is kept
|
||||
# external (next.config.ts serverExternalPackages) and require('sql.js') resolves its WASM
|
||||
# blob from node_modules at runtime.
|
||||
FROM node:22-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1
|
||||
# Full tree (dev+prod deps): a prod-only slimmed copy would need an extra install step,
|
||||
# revisit if image size matters.
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/.next ./.next
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start"]
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
# ORCHESTRATOR.md — Operating Manual for Successor Agents
|
||||
|
||||
You are taking over the autonomous development loop on **SpaceWanderer** (browser game: turn-based space adventure — build & expand your ship/base from found parts and resources, survive random encounters, manage a crew of flawed humans with good/bad traits; server-authoritative turn engine). Gameplay reference: Out There (Android), but more building-focused. This document captures operational knowledge that is NOT written down in AGENTS.md. Read AGENTS.md first — it defines the mandatory change process and this file complements it.
|
||||
You are taking over the autonomous development loop on **SpaceWanderer** (browser game: dungeon-crawler-type, very replayable — turn-based space adventure where you build & expand your ship/base from found parts and resources, survive random encounters, manage a crew of flawed humans with good/bad traits; server-authoritative turn engine). Gameplay reference: Out There (Android), but more building-focused. **Single player only, by design** — no auth/accounts/multiplayer, ever, unless the user changes that decision. This document captures operational knowledge that is NOT written down in AGENTS.md. Read AGENTS.md first — it defines the mandatory change process and this file complements it.
|
||||
|
||||
Credentials are deliberately NOT stored in this file — see §1 for where they live. Never paste token values into any tracked file.
|
||||
|
||||
@@ -10,7 +10,7 @@ Credentials are deliberately NOT stored in this file — see §1 for where they
|
||||
- Prod URL (planned, not deployed yet): https://space-wanderer.dev.kkhost.pl (Traefik → docker-compose stack on this host); first deploy happens after the initial scaffold. Once live, `GET /api/version` reports the running version.
|
||||
- Gitea API access for git.kkhost.pl: prefer the gitea MCP tools — they are already configured with a token. For raw REST/curl, read the current token from the local .env file at repo root (variable `GITEA_TOKEN`; .env is gitignored and must never be committed): `source .env && curl -s -H "Authorization: token $GITEA_TOKEN" https://git.kkhost.pl/api/v1/user`. (Incident note, Sept 2026: an early draft of the Flight-Ops doc committed a live token to the repo; that commit was purged from all refs and the token has since been rotated — never repeat this.)
|
||||
- Current user: kamilkrzywda (id 1).
|
||||
- **Current state:** as of writing, the repo is empty (fresh `git init`, no commits). Step zero for any successor: scaffold from `../flight` — copy architecture + `.gitea/workflows/ci.yml` + `docker-compose*.yml` patterns and adapt names — then this manual's loop applies.
|
||||
- **Current state:** initial scaffold (v0.1.0) is done on branch `scaffold-base` off master — base stack copied/adapted from `../flight`, scificn-ui frontend integrated, CI/e2e pipeline in place; not merged/deployed yet (first deploy happens after the scaffold lands). From here the loop below applies as-is.
|
||||
|
||||
## 2. The loop (per issue)
|
||||
1. Small incremental change on a feature branch off `master`; connected issues may share one branch/PR with a single version bump.
|
||||
@@ -30,7 +30,7 @@ Credentials are deliberately NOT stored in this file — see §1 for where they
|
||||
- CI watcher pattern that works: `run_background_process` (notify=true) running a loop that polls `GET /actions/runs/{id}` every 60 s and exits when status=completed, printing status+conclusion each tick. Don't poll in the foreground; don't sleep-loop on pty_read.
|
||||
|
||||
## 4. CI facts (.gitea/workflows/ci.yml + docker-compose.ci.yml)
|
||||
- Mirrors Flight-Ops: single job "build + unit + e2e" on runner gitea-runner (docker-in-docker). Fresh DB per run (volume removed); compose project `space-wanderer-ci`; app container on a static IP → `E2E_BASE_URL=http://<static-ip>:3000` (exact IP TBD when scaffolding — copy `../flight/.gitea/workflows/ci.yml` + `docker-compose.ci.yml` and adapt); `PW_REMOTE_BROWSER=0` (CI runs local chromium — the shared remote browser server at 192.168.1.4 cannot reach a stack inside dind); `E2E_COMPOSE_ARGS="-f docker-compose.ci.yml -p space-wanderer-ci"` is read by e2e/state.spec.ts for its mid-run `docker compose restart`.
|
||||
- Mirrors Flight-Ops: single job "build + unit + e2e" on runner gitea-runner (docker-in-docker). Fresh DB per run (volume removed); compose project `space-wanderer-ci`; app container on a static IP → `E2E_BASE_URL=http://172.39.0.10:3000` (network `space-wanderer-ci-e2e`, subnet 172.39.0.0/24 — same mechanism as Flight-Ops, different network name so both can coexist on the shared daemon); `PW_REMOTE_BROWSER=0` (CI runs local chromium — the shared remote browser server at 192.168.1.4 cannot reach a stack inside dind); `E2E_COMPOSE_ARGS="-f docker-compose.ci.yml -p space-wanderer-ci"` is read by e2e/state.spec.ts for its mid-run `docker compose restart`.
|
||||
- Flaky uplink → retry loops in ci.yml; an occasional "flaky" test (failed once, passed on retry) does NOT fail the run.
|
||||
- Branch protection requires status check `CI / build + unit + e2e (pull request)` — PRs cannot merge until green; CI never deploys to production.
|
||||
|
||||
@@ -44,12 +44,13 @@ Credentials are deliberately NOT stored in this file — see §1 for where they
|
||||
- None recorded yet — this is where hard-won knowledge accumulates as it is discovered. (Flight-Ops's equivalent sections documented Playwright evaluate serialization dropping prototype getters and MapLibre tile quantization — expect similar "the tool lies to you" surprises with whatever rendering approach lands.)
|
||||
|
||||
## 7. App architecture quick map
|
||||
- **TARGET ARCHITECTURE — code not written yet; scaffold from `../flight`.**
|
||||
- **Scaffolded (v0.1.0)** — stack proof only, no game systems yet; the rest of this section is what's built + what lands next.
|
||||
- Next.js 16 App Router + React 19 + TypeScript strict, bun package manager (bun.lock), Node 22 in CI/Docker.
|
||||
- SQLite via Drizzle ORM + sql.js (WASM, kept external in next.config.ts) at db/space-wanderer.db volume-mounted; schema designed to switch to Postgres later.
|
||||
- **Frontend component layer: scificn-ui v0.1.0** (https://scificn.dev) — copy-paste model on Radix + Tailwind v4; components live in `components/ui/` (Button, Panel, Badge, Card, Alert, Tabs, StatCard, Terminal, StatusGrid, ProgressRing, BarChart, LineChart), theme tokens in `app/globals.css`. The app's interface base is the scificn-ui **Mission Control** showcase layout (https://www.scificn.dev/showcase/mission-control) rebuilt as `components/mission-control.tsx` and rendered from `app/page.tsx`, with placeholder ship content — no game logic yet. Deliberate deviation from upstream: `--text-muted` brightened `#4A4A40` → `#6B6B5C` (+12pt lightness) for readability on the near-black background. Upstream repo has **no LICENSE file** — user-approved risk, keep components minimal and re-verify licensing before any redistribution of this codebase.
|
||||
- SQLite via Drizzle ORM + sql.js (WASM, kept external in next.config.ts) at db/space-wanderer.db volume-mounted; schema designed to switch to Postgres later. Schema is EMPTY at scaffold stage — first game tables land with the turn engine.
|
||||
- Vitest unit tests colocated (`**/*.test.ts`); Playwright e2e in `e2e/`, CI-only.
|
||||
- Server-authoritative TURN engine in lib/ — unlike Flight-Ops there is NO tick loop or sim clock: state advances only when a player action resolves a turn, and every action endpoint returns the full post-action snapshot which the client applies immediately (polling optional/later for multiplayer).
|
||||
- Plain React hooks for client state, no external store; session via stored id + custom header.
|
||||
- Plain React hooks for client state, no external store. **NO auth/accounts/multiplayer by design** (single player only) — there is no users/sessions table and no session headers; a "session" means the local single-player save slot in the DB, not an identity system. Do not copy Flight-Ops's lib/auth.ts / SignInScreen / admin patterns.
|
||||
- Dev/e2e hooks: `window.__sw*` in dev builds + `/api/test/*` behind env flag not set in prod.
|
||||
- Core game systems to build: turn engine, random encounter resolution, ship module grid (build/expand from found parts), resource management, crew with good/bad traits.
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { openApiSpec } from '@/lib/openapi';
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(openApiSpec);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import pkg from '@/package.json';
|
||||
|
||||
export async function GET() {
|
||||
return Response.json({ version: pkg.version });
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap');
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ============================================================
|
||||
TAILWIND V4 THEME — tokens that generate utility classes
|
||||
============================================================ */
|
||||
@theme {
|
||||
/* Original palette — retained */
|
||||
--color-pink: #B53082;
|
||||
--color-purple: #381B57;
|
||||
--color-orange: #F2963A;
|
||||
--color-teal: #6DC3BB;
|
||||
--color-beige: #C8B99A;
|
||||
--color-navy: #0E1628;
|
||||
--color-void: #07050F;
|
||||
|
||||
/* New palette */
|
||||
--color-green: #00ed3f;
|
||||
--color-amber: #ff8800;
|
||||
--color-amber-light: #ffa238;
|
||||
--color-red: #cc2200;
|
||||
--color-blue: #4466cc;
|
||||
--color-bone: #E0D5BE;
|
||||
|
||||
/* Neutral dark backgrounds */
|
||||
--color-bg: #050505;
|
||||
--color-surface: #0D0D0D;
|
||||
--color-surface-raised: #141414;
|
||||
|
||||
/* Typography */
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'Courier New', monospace;
|
||||
|
||||
/* Zero radius */
|
||||
--radius: 0px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SEMANTIC TOKENS — contextual roles mapped to palette
|
||||
============================================================ */
|
||||
:root {
|
||||
/* Backgrounds — neutral dark, no color tint */
|
||||
--background: #050505;
|
||||
--surface: #0D0D0D;
|
||||
--surface-raised: #141414;
|
||||
|
||||
/* Borders */
|
||||
--border: #252525;
|
||||
--border-active: #00ed3f;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #00ed3f;
|
||||
--text-secondary: #E0D5BE;
|
||||
/* SpaceWanderer tweak (v0.1.0): upstream default is #4A4A40 (HSL 60,7%,27%) — too dim for
|
||||
secondary text on the near-black background. Brightened to HSL 60,8%,39% (+12pt lightness,
|
||||
same warm-gray hue) so muted labels stay readable while remaining clearly muted. */
|
||||
--text-muted: #6B6B5C;
|
||||
--text-warning: #ff8800;
|
||||
--text-danger: #cc2200;
|
||||
|
||||
/* Accent shortcuts */
|
||||
--accent-primary: #00ed3f;
|
||||
--accent-secondary: #4466cc;
|
||||
--accent-warning: #ff8800;
|
||||
|
||||
/* Glow — original (kept for backward compat) */
|
||||
--glow-teal: 0 0 8px #6DC3BB66, 0 0 20px #6DC3BB33;
|
||||
--glow-pink: 0 0 8px #B5308266, 0 0 20px #B5308233;
|
||||
--glow-orange: 0 0 8px #F2963A66, 0 0 20px #F2963A33;
|
||||
|
||||
/* Glow — new palette */
|
||||
--glow-green: 0 0 8px #00ed3f66, 0 0 20px #00ed3f33;
|
||||
--glow-amber: 0 0 8px #ff880066, 0 0 20px #ff880033;
|
||||
--glow-red: 0 0 8px #cc220066, 0 0 20px #cc220033;
|
||||
--glow-blue: 0 0 8px #4466cc66, 0 0 20px #4466cc33;
|
||||
|
||||
/* Text glow — original (kept) */
|
||||
--text-glow-teal: 0 0 6px #6DC3BB99, 0 0 14px #6DC3BB55;
|
||||
--text-glow-orange: 0 0 6px #F2963A99, 0 0 14px #F2963A55;
|
||||
--text-glow-pink: 0 0 6px #B5308299, 0 0 14px #B5308255;
|
||||
|
||||
/* Text glow — new palette */
|
||||
--text-glow-green: 0 0 6px #00ed3f99, 0 0 14px #00ed3f55;
|
||||
--text-glow-amber: 0 0 6px #ff880099, 0 0 14px #ff880055;
|
||||
--text-glow-red: 0 0 6px #cc220099, 0 0 14px #cc220055;
|
||||
--text-glow-blue: 0 0 6px #4466cc99, 0 0 14px #4466cc55;
|
||||
|
||||
/* Corner notch clip-paths */
|
||||
--clip-corner-sm: polygon(6px 0%, 100% 0%, 100% calc(100% - 6px), calc(100% - 6px) 100%, 0% 100%, 0% 6px);
|
||||
--clip-corner-md: polygon(10px 0%, 100% 0%, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0% 100%, 0% 10px);
|
||||
--clip-corner-lg: polygon(16px 0%, 100% 0%, 100% calc(100% - 16px), calc(100% - 16px) 100%, 0% 100%, 0% 16px);
|
||||
|
||||
/* Geometry */
|
||||
--border-width: 1px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
KEYFRAME ANIMATIONS
|
||||
============================================================ */
|
||||
@keyframes flicker {
|
||||
0%, 89%, 91%, 93%, 100% { opacity: 1; }
|
||||
90% { opacity: 0.4; }
|
||||
92% { opacity: 0.8; }
|
||||
94% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
@keyframes blink-cursor {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes glitch {
|
||||
0%, 100% { transform: translate(0); }
|
||||
10% { transform: translate(-2px, 1px); }
|
||||
20% { transform: translate(2px, -1px); }
|
||||
30% { transform: translate(-1px, 2px); }
|
||||
40% { transform: translate(1px, -1px); }
|
||||
50% { transform: translate(0); }
|
||||
}
|
||||
|
||||
@keyframes scanline-move {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(100vh); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes data-scroll {
|
||||
0% { transform: translateY(0); }
|
||||
100% { transform: translateY(-50%); }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BASE RESET
|
||||
============================================================ */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 14px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--background);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: var(--font-mono);
|
||||
font-size: inherit;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-shadow: var(--text-glow-green);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--background);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-active);
|
||||
box-shadow: var(--glow-green);
|
||||
}
|
||||
|
||||
/* Text selection */
|
||||
::selection {
|
||||
background: #00ed3f;
|
||||
color: #050505;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
UTILITY CLASSES
|
||||
============================================================ */
|
||||
|
||||
/* CRT scanline overlay — apply to panels/containers */
|
||||
.scanlines {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scanlines::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent 0px,
|
||||
transparent 3px,
|
||||
rgba(0, 0, 0, 0.12) 3px,
|
||||
rgba(0, 0, 0, 0.12) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Phosphor flicker text */
|
||||
.flicker {
|
||||
animation: flicker 8s infinite;
|
||||
}
|
||||
|
||||
/* Glitch on hover */
|
||||
.glitch-hover:hover {
|
||||
animation: glitch 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Pulsing glow */
|
||||
.pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Corner notch variants */
|
||||
.clip-corner-sm {
|
||||
clip-path: var(--clip-corner-sm);
|
||||
}
|
||||
.clip-corner-md {
|
||||
clip-path: var(--clip-corner-md);
|
||||
}
|
||||
.clip-corner-lg {
|
||||
clip-path: var(--clip-corner-lg);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
THEME: STAR WARS
|
||||
Holographic blue primary · bone/gold warnings · teal info
|
||||
Deep blue-black backgrounds
|
||||
============================================================ */
|
||||
[data-theme="star-wars"] {
|
||||
/* Remap color primitives used by components */
|
||||
--color-green: #1a6dff; /* blue hologram = "active" */
|
||||
--color-amber: #E0D5BE; /* bone/gold = "warning" */
|
||||
--color-red: #cc0000; /* Sith red = "danger" */
|
||||
--color-blue: #6DC3BB; /* teal = "info" */
|
||||
|
||||
/* Backgrounds — deep blue-black */
|
||||
--background: #020509;
|
||||
--surface: #060E18;
|
||||
--surface-raised: #0C1826;
|
||||
|
||||
/* Borders */
|
||||
--border: #152030;
|
||||
--border-active: #1a6dff;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #1a6dff;
|
||||
--text-secondary: #E0D5BE;
|
||||
--text-muted: #2A3A52;
|
||||
--text-warning: #E0D5BE;
|
||||
--text-danger: #cc0000;
|
||||
|
||||
/* Accent shortcuts */
|
||||
--accent-primary: #1a6dff;
|
||||
--accent-secondary: #6DC3BB;
|
||||
--accent-warning: #E0D5BE;
|
||||
|
||||
/* Glow — remap green→blue, amber→bone, blue→teal */
|
||||
--glow-green: 0 0 8px rgba(26,109,255,0.40), 0 0 20px rgba(26,109,255,0.20);
|
||||
--glow-amber: 0 0 8px rgba(224,213,190,0.35), 0 0 20px rgba(224,213,190,0.15);
|
||||
--glow-red: 0 0 8px rgba(204,0,0,0.40), 0 0 20px rgba(204,0,0,0.20);
|
||||
--glow-blue: 0 0 8px rgba(109,195,187,0.40), 0 0 20px rgba(109,195,187,0.20);
|
||||
|
||||
/* Text glow — remap */
|
||||
--text-glow-green: 0 0 6px rgba(26,109,255,0.60), 0 0 14px rgba(26,109,255,0.30);
|
||||
--text-glow-amber: 0 0 6px rgba(224,213,190,0.55), 0 0 14px rgba(224,213,190,0.25);
|
||||
--text-glow-red: 0 0 6px rgba(204,0,0,0.60), 0 0 14px rgba(204,0,0,0.30);
|
||||
--text-glow-blue: 0 0 6px rgba(109,195,187,0.60), 0 0 14px rgba(109,195,187,0.30);
|
||||
}
|
||||
|
||||
[data-theme="star-wars"] ::selection {
|
||||
background: #1a6dff;
|
||||
color: #020509;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
THEME: ALIEN
|
||||
Bone/ivory primary · amber warnings · acid green info
|
||||
Warm organic dark backgrounds
|
||||
============================================================ */
|
||||
[data-theme="alien"] {
|
||||
/* Remap color primitives used by components */
|
||||
--color-green: #E0D5BE; /* bone = "active" (bleached, eerie) */
|
||||
--color-amber: #ff8800; /* amber = "warning" (Nostromo lights) */
|
||||
--color-red: #cc2200; /* blood red = "danger" */
|
||||
--color-blue: #00ed3f; /* acid green = "info" (bioluminescent) */
|
||||
|
||||
/* Backgrounds — warm organic dark */
|
||||
--background: #040302;
|
||||
--surface: #080604;
|
||||
--surface-raised: #0E0A06;
|
||||
|
||||
/* Borders */
|
||||
--border: #201808;
|
||||
--border-active: #E0D5BE;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #E0D5BE;
|
||||
--text-secondary: #C8B99A;
|
||||
--text-muted: #403020;
|
||||
--text-warning: #ff8800;
|
||||
--text-danger: #cc2200;
|
||||
|
||||
/* Accent shortcuts */
|
||||
--accent-primary: #E0D5BE;
|
||||
--accent-secondary: #00ed3f;
|
||||
--accent-warning: #ff8800;
|
||||
|
||||
/* Glow — remap green→bone, blue→acid-green */
|
||||
--glow-green: 0 0 8px rgba(224,213,190,0.35), 0 0 20px rgba(224,213,190,0.15);
|
||||
--glow-amber: 0 0 8px rgba(255,136,0,0.40), 0 0 20px rgba(255,136,0,0.20);
|
||||
--glow-red: 0 0 8px rgba(204,34,0,0.40), 0 0 20px rgba(204,34,0,0.20);
|
||||
--glow-blue: 0 0 8px rgba(0,237,63,0.40), 0 0 20px rgba(0,237,63,0.20);
|
||||
|
||||
/* Text glow — remap */
|
||||
--text-glow-green: 0 0 6px rgba(224,213,190,0.55), 0 0 14px rgba(224,213,190,0.25);
|
||||
--text-glow-amber: 0 0 6px rgba(255,136,0,0.60), 0 0 14px rgba(255,136,0,0.30);
|
||||
--text-glow-red: 0 0 6px rgba(204,34,0,0.60), 0 0 14px rgba(204,34,0,0.30);
|
||||
--text-glow-blue: 0 0 6px rgba(0,237,63,0.60), 0 0 14px rgba(0,237,63,0.30);
|
||||
}
|
||||
|
||||
[data-theme="alien"] ::selection {
|
||||
background: #E0D5BE;
|
||||
color: #040302;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" fill="#050505"/>
|
||||
<circle cx="32" cy="32" r="14" fill="none" stroke="#00ed3f" stroke-width="2.5"/>
|
||||
<ellipse cx="32" cy="32" rx="26" ry="8" fill="none" stroke="#E0D5BE" stroke-width="2" transform="rotate(-18 32 32)"/>
|
||||
<circle cx="49" cy="22" r="2.5" fill="#ff8800"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 370 B |
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'SpaceWanderer',
|
||||
description: 'Turn-based space adventure — build & expand your ship, survive the void.',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { MissionControl } from '@/components/mission-control';
|
||||
|
||||
export const metadata = {
|
||||
title: 'SpaceWanderer',
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
return <MissionControl />;
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "space-wanderer",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"next": "^16.3.5",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"sql.js": "^1.14.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@types/node": "^22.20.3",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/sql.js": "^1.4.11",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.3.0", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="],
|
||||
|
||||
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="],
|
||||
|
||||
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@next/env": ["@next/env@16.3.6", "", {}, "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.151.0", "", {}, "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="],
|
||||
|
||||
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="],
|
||||
|
||||
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.11", "", { "os": "linux", "cpu": "arm" }, "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.11", "", { "os": "none", "cpu": "arm64" }, "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
|
||||
|
||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.3", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/emscripten": ["@types/emscripten@1.41.6", "", {}, "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/node": ["@types/node@22.20.4", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA=="],
|
||||
|
||||
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
|
||||
|
||||
"@types/sql.js": ["@types/sql.js@1.4.11", "", { "dependencies": { "@types/emscripten": "*", "@types/node": "*" } }, "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ=="],
|
||||
|
||||
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||
|
||||
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
|
||||
|
||||
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
|
||||
|
||||
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
|
||||
|
||||
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
|
||||
|
||||
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
|
||||
|
||||
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
|
||||
|
||||
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
|
||||
|
||||
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
|
||||
|
||||
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
|
||||
|
||||
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
|
||||
|
||||
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
|
||||
|
||||
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
|
||||
|
||||
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
|
||||
|
||||
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
|
||||
|
||||
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
|
||||
|
||||
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
|
||||
|
||||
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
|
||||
|
||||
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
|
||||
|
||||
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@5.0.2", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.2", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@5.0.2", "", {}, "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.26", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001813", "", {}, "sha512-zfjJo4rM0+fUomGDBW/xcDjhIwz/210DGvip2MAMDZ8KHcRPnOHmEgHPZP0UHlZoxr8fYKVJOqcORhYQcG4FKQ=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.45.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
|
||||
|
||||
"next": ["next@16.3.6", "", { "dependencies": { "@next/env": "16.3.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.6", "@next/swc-darwin-x64": "16.3.6", "@next/swc-linux-arm64-gnu": "16.3.6", "@next/swc-linux-arm64-musl": "16.3.6", "@next/swc-linux-x64-gnu": "16.3.6", "@next/swc-linux-x64-musl": "16.3.6", "@next/swc-win32-arm64-msvc": "16.3.6", "@next/swc-win32-x64-msvc": "16.3.6", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw=="],
|
||||
|
||||
"obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||
|
||||
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
|
||||
|
||||
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
|
||||
|
||||
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.11", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.11", "@rolldown/binding-android-arm64": "1.2.11", "@rolldown/binding-darwin-arm64": "1.2.11", "@rolldown/binding-darwin-x64": "1.2.11", "@rolldown/binding-freebsd-x64": "1.2.11", "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", "@rolldown/binding-linux-arm64-gnu": "1.2.11", "@rolldown/binding-linux-arm64-musl": "1.2.11", "@rolldown/binding-linux-ppc64-gnu": "1.2.11", "@rolldown/binding-linux-s390x-gnu": "1.2.11", "@rolldown/binding-linux-x64-gnu": "1.2.11", "@rolldown/binding-linux-x64-musl": "1.2.11", "@rolldown/binding-openharmony-arm64": "1.2.11", "@rolldown/binding-win32-arm64-msvc": "1.2.11", "@rolldown/binding-win32-x64-msvc": "1.2.11" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw=="],
|
||||
|
||||
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
|
||||
|
||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"sql.js": ["sql.js@1.14.2", "", {}, "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw=="],
|
||||
|
||||
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.7.0", "", {}, "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tinybench": ["tinybench@6.2.0", "", {}, "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"vite": ["vite@8.3.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA=="],
|
||||
|
||||
"vitest": ["vitest@5.0.2", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.2", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "^6.1.4", "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.2", "@vitest/browser-preview": "5.0.2", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.2", "@vitest/coverage-v8": "5.0.2", "@vitest/ui": "5.0.2", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@3.2.2", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A=="],
|
||||
|
||||
"@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@types/sql.js/@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="],
|
||||
|
||||
"next/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
|
||||
|
||||
"vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||
|
||||
"@types/sql.js/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||
|
||||
"vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
|
||||
|
||||
"vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
|
||||
|
||||
"vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
|
||||
|
||||
"vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
|
||||
|
||||
"vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
|
||||
|
||||
"vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
|
||||
|
||||
"vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
|
||||
|
||||
"vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
|
||||
|
||||
"vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
|
||||
|
||||
"vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { BarChart } from '@/components/ui/bar-chart'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { LineChart } from '@/components/ui/line-chart'
|
||||
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
||||
import { ProgressRing } from '@/components/ui/progress-ring'
|
||||
import { StatCard } from '@/components/ui/stat-card'
|
||||
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
||||
|
||||
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
|
||||
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui), with
|
||||
// placeholder SpaceWanderer content — no game logic yet. Single player, no auth, by design.
|
||||
|
||||
function useNarrow(threshold = 900): boolean {
|
||||
const [narrow, setNarrow] = useState(
|
||||
() => typeof window !== 'undefined' && window.innerWidth < threshold
|
||||
)
|
||||
useEffect(() => {
|
||||
const handle = () => setNarrow(window.innerWidth < threshold)
|
||||
window.addEventListener('resize', handle, { passive: true })
|
||||
return () => window.removeEventListener('resize', handle)
|
||||
}, [threshold])
|
||||
return narrow
|
||||
}
|
||||
|
||||
function now(): string {
|
||||
return new Date().toLocaleTimeString('en-GB', { hour12: false })
|
||||
}
|
||||
|
||||
// ── Placeholder content (no game logic — real values land with the turn engine) ──
|
||||
|
||||
const shipSystems: SystemEntry[] = [
|
||||
{ name: 'REACTOR', status: 'ACTIVE', detail: '94.2%' },
|
||||
{ name: 'LIFE SUPPORT', status: 'ACTIVE' },
|
||||
{ name: 'NAV COMPUTER', status: 'SCANNING' },
|
||||
{ name: 'WEAPONS', status: 'OFFLINE' },
|
||||
{ name: 'COMMS', status: 'ACTIVE' },
|
||||
]
|
||||
|
||||
const bayOutput = [
|
||||
{ label: 'PWR', value: 14 },
|
||||
{ label: 'NAV', value: 9 },
|
||||
{ label: 'COM', value: 6 },
|
||||
{ label: 'WPN', value: 3 },
|
||||
{ label: 'LSS', value: 12 },
|
||||
{ label: 'ENG', value: 11 },
|
||||
{ label: 'BAY', value: 7 },
|
||||
{ label: 'MED', value: 5 },
|
||||
]
|
||||
|
||||
const hullTrack = [100, 98, 97, 96, 94, 95, 92, 90, 89, 87]
|
||||
const hullLabels = hullTrack.map((_, i) => `T-${hullTrack.length - 1 - i}`)
|
||||
|
||||
const encounters = [
|
||||
{ name: 'DERELICT FREIGHTER', when: 'T-07:14', id: '#E-102' },
|
||||
{ name: 'PIRATE SKIRMISH', when: 'T-22:40', id: '#E-103' },
|
||||
{ name: 'ANOMALY FIELD', when: 'T-48:12', id: '#E-104' },
|
||||
]
|
||||
|
||||
const shipData = [
|
||||
{ label: 'TOTAL MODULES', value: '34' },
|
||||
{ label: 'HULL INTEGRITY', value: '87%' },
|
||||
{ label: 'REACTOR OUTPUT', value: '94.2%' },
|
||||
{ label: 'CREW ACTIVE', value: '3 / 6' },
|
||||
{ label: 'SECTOR', value: '7G // DRIFT LANE' },
|
||||
{ label: 'DRIFT VELOCITY', value: '0.4 KM/S' },
|
||||
{ label: 'TURN', value: '12' },
|
||||
]
|
||||
|
||||
const logLines: TerminalLine[] = [
|
||||
{ type: 'system', text: 'SPACEWANDERER // INITIALIZING SHIP SYSTEMS...', timestamp: now() },
|
||||
{ type: 'output', text: 'HULL SCAN COMPLETE — INTEGRITY 87%', timestamp: now() },
|
||||
{ type: 'output', text: 'REACTOR OUTPUT STABLE AT 94.2%', timestamp: now() },
|
||||
{ type: 'output', text: 'CREW MANIFEST LOADED — 3 ACTIVE PERSONNEL', timestamp: now() },
|
||||
{ type: 'warn', text: 'WEAPONS OFFLINE — MODULE NOT INSTALLED', timestamp: now() },
|
||||
{ type: 'system', text: 'ALL SYSTEMS NOMINAL. AWAITING FIRST TURN.', timestamp: now() },
|
||||
]
|
||||
|
||||
export function MissionControl() {
|
||||
const narrow = useNarrow()
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false })
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: 'var(--background)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
{/* ── HEADER ── */}
|
||||
<header
|
||||
style={{
|
||||
height: '44px',
|
||||
background: 'var(--surface)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 1.25rem',
|
||||
gap: '0.75rem',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.08em',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
◄ SHIP
|
||||
</span>
|
||||
|
||||
<span style={{ color: 'var(--border)', flexShrink: 0 }}>│</span>
|
||||
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
color: 'var(--color-green)',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.12em',
|
||||
textShadow: 'var(--text-glow-green)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
SPACEWANDERER
|
||||
</h1>
|
||||
|
||||
{!narrow && (
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '0.72rem',
|
||||
letterSpacing: '0.08em',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
// SHIP STATUS // CREW MANIFEST
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{!narrow && (
|
||||
<Badge variant="ACTIVE">SHIP NOMINAL</Badge>
|
||||
)}
|
||||
|
||||
<Badge variant="SCANNING">SECTOR SCAN</Badge>
|
||||
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--color-green)',
|
||||
fontSize: '0.65rem',
|
||||
letterSpacing: '0.1em',
|
||||
textShadow: 'var(--text-glow-green)',
|
||||
flexShrink: 0,
|
||||
opacity: tick % 2 === 0 ? 1 : 0.5,
|
||||
transition: 'opacity 0.3s',
|
||||
}}
|
||||
>
|
||||
{timeStr}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{/* ── BODY ── */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1px',
|
||||
background: 'var(--border)',
|
||||
}}
|
||||
>
|
||||
{/* STAT CARDS — placeholder ship readouts */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--background)',
|
||||
padding: '0.75rem 1rem',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: narrow ? '1fr 1fr' : 'repeat(4, 1fr)',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<StatCard
|
||||
label="HULL INTEGRITY"
|
||||
value="87%"
|
||||
sublabel="NOMINAL · NO BREACHES"
|
||||
variant="ACTIVE"
|
||||
/>
|
||||
<StatCard
|
||||
label="REACTOR OUTPUT"
|
||||
value="94.2%"
|
||||
sublabel="STABLE · IDLE CYCLE"
|
||||
variant="ACTIVE"
|
||||
/>
|
||||
<StatCard
|
||||
label="CREW ON BOARD"
|
||||
value="3 / 6"
|
||||
sublabel="AWAITING MANIFEST"
|
||||
variant="DEFAULT"
|
||||
/>
|
||||
<StatCard
|
||||
label="NEXT ENCOUNTER"
|
||||
value="T-07:14"
|
||||
sublabel="SECTOR 7G // DRIFT LANE"
|
||||
variant="WARNING"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3-COLUMN GRID */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: narrow ? '1fr' : '260px 1fr 240px',
|
||||
gap: '1px',
|
||||
background: 'var(--border)',
|
||||
}}
|
||||
>
|
||||
{/* ── LEFT: SHIP SYSTEMS + VESSEL METRICS ── */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--background)',
|
||||
padding: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.875rem',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<Panel notch="sm">
|
||||
<PanelHeader>
|
||||
<PanelTitle>SHIP SYSTEMS</PanelTitle>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<Badge variant="ACTIVE">4 ONLINE</Badge>
|
||||
</div>
|
||||
</PanelHeader>
|
||||
|
||||
<StatusGrid systems={shipSystems} />
|
||||
</Panel>
|
||||
|
||||
<Panel notch="sm">
|
||||
<PanelHeader>
|
||||
<PanelTitle>VESSEL METRICS</PanelTitle>
|
||||
</PanelHeader>
|
||||
<PanelContent>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
paddingTop: '0.5rem',
|
||||
paddingBottom: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<ProgressRing value={87} label="HULL" variant="ACTIVE" size={74} />
|
||||
<ProgressRing value={94} label="REACTOR" variant="ACTIVE" size={74} />
|
||||
<ProgressRing value={50} label="CREW / 6" variant="WARNING" size={74} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.75rem',
|
||||
paddingTop: '0.75rem',
|
||||
borderTop: '1px solid var(--border)',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: '0.25rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: 'HULL INTEGRITY', note: 'last scan T-02' },
|
||||
{ label: 'REACTOR OUTPUT', note: 'idle cycle' },
|
||||
{ label: 'CREW ROSTER', note: 'of 6 berths' },
|
||||
].map((item) => (
|
||||
<div key={item.label}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.52rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.48rem',
|
||||
color: 'var(--border)',
|
||||
letterSpacing: '0.06em',
|
||||
marginTop: '1px',
|
||||
}}
|
||||
>
|
||||
{item.note}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PanelContent>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* ── CENTER: OPERATIONS TABS ── */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--background)',
|
||||
padding: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.875rem',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<Panel notch="md" style={{ flex: 1 }}>
|
||||
<PanelHeader>
|
||||
<PanelTitle>OPERATIONS // SHIP COMMAND</PanelTitle>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
||||
<Badge variant="ACTIVE">SYS NOMINAL</Badge>
|
||||
</div>
|
||||
</PanelHeader>
|
||||
|
||||
<PanelContent style={{ padding: 0 }}>
|
||||
<Tabs defaultValue="systems">
|
||||
<TabsList
|
||||
style={{ padding: '0 1rem', background: 'var(--surface-raised)' }}
|
||||
>
|
||||
<TabsTrigger value="systems">SYSTEMS</TabsTrigger>
|
||||
<TabsTrigger value="next">NEXT MISSION</TabsTrigger>
|
||||
<TabsTrigger value="log">LOG</TabsTrigger>
|
||||
<TabsTrigger value="hull">HULL TRACK</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* SYSTEMS TAB */}
|
||||
<TabsContent value="systems" style={{ margin: 0, padding: '1rem' }}>
|
||||
<BarChart
|
||||
data={bayOutput}
|
||||
orientation="vertical"
|
||||
variant="ACTIVE"
|
||||
title="MODULE OUTPUT BY BAY (PLACEHOLDER)"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '0.625rem',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: 'TOTAL MODULES', value: 34 },
|
||||
{ label: 'ONLINE', value: 29 },
|
||||
{ label: 'EFFICIENCY', value: '85%' },
|
||||
].map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.55rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.12em',
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-green)',
|
||||
textShadow: 'var(--text-glow-green)',
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{s.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* NEXT MISSION TAB */}
|
||||
<TabsContent value="next" style={{ margin: 0, padding: '1rem' }}>
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
||||
>
|
||||
<Alert variant="STATUS">
|
||||
<AlertTitle>ENCOUNTER #E-102 — DERELICT FREIGHTER</AlertTitle>
|
||||
<AlertDescription>
|
||||
Placeholder briefing. Real encounter resolution lands with the turn engine.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: narrow ? '1fr' : '1fr 1fr',
|
||||
gap: '0.625rem',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: 'DESTINATION', value: 'SECTOR 7G // DRIFT LANE' },
|
||||
{ label: 'ETA', value: 'T-07:14 (PLACEHOLDER)' },
|
||||
{ label: 'THREAT LEVEL', value: 'LOW — UNMANNED' },
|
||||
{ label: 'CARGO MANIFEST', value: 'UNKNOWN' },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.55rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.12em',
|
||||
marginBottom: '0.35rem',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--text-secondary)',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* LOG TAB */}
|
||||
<TabsContent value="log" style={{ margin: 0, padding: '1rem' }}>
|
||||
<Terminal
|
||||
lines={logLines}
|
||||
title="SHIP LOG // PLACEHOLDER FEED"
|
||||
height="22rem"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* HULL TRACK TAB */}
|
||||
<TabsContent value="hull" style={{ margin: 0, padding: '1rem' }}>
|
||||
<LineChart
|
||||
series={[{ id: 'hull', label: 'HULL INTEGRITY %', data: hullTrack }]}
|
||||
labels={hullLabels}
|
||||
title="HULL INTEGRITY TRACK // PLACEHOLDER"
|
||||
showArea
|
||||
animated={false}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PanelContent>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* ── RIGHT: UPCOMING ENCOUNTERS + SHIP DATA ── */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--background)',
|
||||
padding: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.875rem',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<Panel notch="sm">
|
||||
<PanelHeader>
|
||||
<PanelTitle>UPCOMING ENCOUNTERS</PanelTitle>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<Badge variant="SCANNING">{encounters.length} QUEUED</Badge>
|
||||
</div>
|
||||
</PanelHeader>
|
||||
<PanelContent style={{ padding: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{encounters.map((enc, i) => {
|
||||
const isNext = i === 0
|
||||
const isLast = i === encounters.length - 1
|
||||
return (
|
||||
<div
|
||||
key={enc.id}
|
||||
style={{
|
||||
padding: '0.6rem 0.75rem',
|
||||
borderBottom: isLast ? 'none' : '1px solid var(--border)',
|
||||
background: isNext ? 'var(--surface-raised)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ overflow: 'hidden', flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.68rem',
|
||||
color: isNext ? 'var(--text-secondary)' : 'var(--text-muted)',
|
||||
letterSpacing: '0.04em',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{enc.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.57rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{enc.when} // {enc.id}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={isNext ? 'WARNING' : 'SCANNING'}>
|
||||
{isNext ? 'NEXT' : 'SCHED'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PanelContent>
|
||||
</Panel>
|
||||
|
||||
<Panel notch="sm">
|
||||
<PanelHeader>
|
||||
<PanelTitle>SHIP DATA</PanelTitle>
|
||||
</PanelHeader>
|
||||
<PanelContent>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{shipData.map((item, i) => (
|
||||
<div
|
||||
key={item.label}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0.4rem 0',
|
||||
borderBottom:
|
||||
i < shipData.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.1em',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--color-green)',
|
||||
textShadow: 'var(--text-glow-green)',
|
||||
letterSpacing: '0.04em',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PanelContent>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── FOOTER ── */}
|
||||
<footer
|
||||
style={{
|
||||
minHeight: '44px',
|
||||
background: 'var(--surface)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
padding: '0.5rem 1.25rem',
|
||||
gap: '0.5rem',
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 50,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||
INTERFACE BASE: SCIFICN MISSION CONTROL
|
||||
</span>
|
||||
<span style={{ color: 'var(--border)' }}>│</span>
|
||||
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||
CONTENT: PLACEHOLDER — NO GAME LOGIC YET
|
||||
</span>
|
||||
<span style={{ color: 'var(--border)' }}>│</span>
|
||||
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||
SINGLE PLAYER // NO AUTH
|
||||
</span>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<Badge variant="ACTIVE">SYS ONLINE</Badge>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const alertVariants = cva(
|
||||
[
|
||||
'relative w-full',
|
||||
'border border-solid border-l-[3px]',
|
||||
'p-4',
|
||||
'font-mono',
|
||||
'rounded-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
STATUS: [
|
||||
'border-[var(--border)]',
|
||||
'border-l-[var(--color-green)]',
|
||||
'bg-[var(--surface)]',
|
||||
],
|
||||
WARNING: [
|
||||
'border-[var(--border)]',
|
||||
'border-l-[var(--color-amber)]',
|
||||
'bg-[var(--surface)]',
|
||||
],
|
||||
CRITICAL: [
|
||||
'border-[var(--border)]',
|
||||
'border-l-[var(--color-red)]',
|
||||
'bg-[var(--surface)]',
|
||||
],
|
||||
INFO: [
|
||||
'border-[var(--border)]',
|
||||
'border-l-[var(--color-blue)]',
|
||||
'bg-[var(--surface)]',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'STATUS' },
|
||||
}
|
||||
)
|
||||
|
||||
const prefixMap = {
|
||||
STATUS: { symbol: '◈', color: 'var(--color-green)', glow: 'var(--text-glow-green)' },
|
||||
WARNING: { symbol: '⚠', color: 'var(--color-amber)', glow: 'var(--text-glow-amber)' },
|
||||
CRITICAL: { symbol: '✕', color: 'var(--color-red)', glow: 'var(--text-glow-red)' },
|
||||
INFO: { symbol: 'ℹ', color: 'var(--color-blue)', glow: 'var(--text-glow-blue)' },
|
||||
}
|
||||
|
||||
export interface AlertProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof alertVariants> {}
|
||||
|
||||
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
||||
({ className, variant = 'STATUS', children, ...props }, ref) => {
|
||||
const v = variant ?? 'STATUS'
|
||||
const prefix = prefixMap[v]
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn(alertVariants({ variant }), className)} role="alert" {...props}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start' }}>
|
||||
<span
|
||||
style={{
|
||||
color: prefix.color,
|
||||
textShadow: prefix.glow,
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{prefix.symbol}
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Alert.displayName = 'Alert'
|
||||
|
||||
/* ── AlertTitle ── */
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, style, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: '0.25rem',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
AlertTitle.displayName = 'AlertTitle'
|
||||
|
||||
/* ── AlertDescription ── */
|
||||
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, style, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--text-muted)',
|
||||
lineHeight: 1.6,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
AlertDescription.displayName = 'AlertDescription'
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, alertVariants }
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
[
|
||||
'inline-flex items-center gap-1.5',
|
||||
'px-2 py-0.5',
|
||||
'text-[0.65rem] font-mono font-medium uppercase tracking-widest',
|
||||
'border border-solid',
|
||||
'rounded-none',
|
||||
'whitespace-nowrap',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
ACTIVE: [
|
||||
'border-[var(--color-green)]',
|
||||
'text-[var(--color-green)]',
|
||||
],
|
||||
OFFLINE: [
|
||||
'border-[var(--border)]',
|
||||
'text-[var(--text-muted)]',
|
||||
],
|
||||
WARNING: [
|
||||
'border-[var(--color-amber)]',
|
||||
'text-[var(--color-amber)]',
|
||||
],
|
||||
CRITICAL: [
|
||||
'border-[var(--color-red)]',
|
||||
'text-[var(--color-red)]',
|
||||
],
|
||||
SCANNING: [
|
||||
'border-[var(--color-green)]',
|
||||
'text-[var(--color-green)]',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'ACTIVE',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, children, ...props }: BadgeProps) {
|
||||
const isScanning = variant === 'SCANNING'
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
style={
|
||||
variant === 'ACTIVE'
|
||||
? { textShadow: 'var(--text-glow-green)', boxShadow: 'inset 0 0 8px #00ed3f11' }
|
||||
: variant === 'CRITICAL'
|
||||
? { textShadow: 'var(--text-glow-red)', boxShadow: 'inset 0 0 8px #cc220011' }
|
||||
: undefined
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{isScanning && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
animation: 'blink-cursor 1s step-end infinite',
|
||||
}}
|
||||
>
|
||||
●
|
||||
</span>
|
||||
)}
|
||||
{!isScanning && variant === 'ACTIVE' && <span>●</span>}
|
||||
{!isScanning && variant === 'OFFLINE' && <span>○</span>}
|
||||
{!isScanning && variant === 'WARNING' && <span>⚠</span>}
|
||||
{!isScanning && variant === 'CRITICAL' && <span>✕</span>}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,257 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type BarChartVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'
|
||||
|
||||
export interface BarChartEntry {
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface BarChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
data: BarChartEntry[]
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
variant?: BarChartVariant
|
||||
title?: string
|
||||
showValues?: boolean
|
||||
/** Override the max value for scaling. Defaults to max(data). */
|
||||
max?: number
|
||||
}
|
||||
|
||||
const VERTICAL_CHART_HEIGHT = 144 // px — 9rem
|
||||
|
||||
const BarChart = React.forwardRef<HTMLDivElement, BarChartProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
data,
|
||||
orientation = 'horizontal',
|
||||
variant = 'ACTIVE',
|
||||
title,
|
||||
showValues = true,
|
||||
max,
|
||||
style,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const computedMax = max ?? Math.max(...data.map((d) => d.value), 1)
|
||||
|
||||
const accentColor =
|
||||
variant === 'ACTIVE' ? 'var(--color-green)' :
|
||||
variant === 'WARNING' ? 'var(--color-amber)' :
|
||||
variant === 'CRITICAL' ? 'var(--color-red)' :
|
||||
'var(--text-secondary)'
|
||||
|
||||
const accentGlow =
|
||||
variant === 'ACTIVE' ? 'var(--glow-green)' :
|
||||
variant === 'WARNING' ? 'var(--glow-amber)' :
|
||||
variant === 'CRITICAL' ? 'var(--glow-red)' :
|
||||
'none'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{/* Title bar */}
|
||||
{title && (
|
||||
<div
|
||||
style={{
|
||||
padding: '0.4rem 0.75rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--surface-raised)',
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orientation === 'horizontal' ? (
|
||||
/* ── Horizontal bars ── */
|
||||
<div
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{data.map((entry, i) => {
|
||||
const pct = Math.max((entry.value / computedMax) * 100, 0)
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
{/* Label */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
width: 'clamp(3rem, 20%, 7rem)',
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{entry.label}
|
||||
</span>
|
||||
|
||||
{/* Track */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '0.65rem',
|
||||
background: 'var(--surface-raised)',
|
||||
border: '1px solid var(--border)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: `${pct}%`,
|
||||
background: accentColor,
|
||||
boxShadow: accentGlow,
|
||||
opacity: 0.85,
|
||||
transition: 'width 0.4s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
{showValues && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
color: accentColor,
|
||||
letterSpacing: '0.04em',
|
||||
width: '3rem',
|
||||
textAlign: 'right',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{entry.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* ── Vertical bars ── */
|
||||
<div>
|
||||
{/* Bar area */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: '0.4rem',
|
||||
height: VERTICAL_CHART_HEIGHT,
|
||||
padding: '0.75rem 0.75rem 0',
|
||||
}}
|
||||
>
|
||||
{data.map((entry, i) => {
|
||||
const pct = Math.max((entry.value / computedMax) * 100, 0)
|
||||
const barPx = Math.round((pct / 100) * VERTICAL_CHART_HEIGHT)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: VERTICAL_CHART_HEIGHT,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.2rem',
|
||||
}}
|
||||
>
|
||||
{/* Value above bar */}
|
||||
{showValues && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.5rem',
|
||||
color: accentColor,
|
||||
letterSpacing: '0.04em',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{entry.value}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Bar */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: barPx > 0 ? barPx : 0,
|
||||
minHeight: entry.value > 0 ? 2 : 0,
|
||||
background: accentColor,
|
||||
boxShadow: accentGlow,
|
||||
opacity: 0.85,
|
||||
flexShrink: 0,
|
||||
transition: 'height 0.4s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Labels row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.4rem',
|
||||
padding: '0.25rem 0.75rem 0.75rem',
|
||||
borderTop: '1px solid var(--border)',
|
||||
marginTop: '0.25rem',
|
||||
}}
|
||||
>
|
||||
{data.map((entry, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
fontSize: '0.5rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{entry.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
BarChart.displayName = 'BarChart'
|
||||
|
||||
export { BarChart }
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
[
|
||||
'inline-flex items-center justify-center',
|
||||
'font-mono font-medium uppercase tracking-widest',
|
||||
'border border-solid',
|
||||
'cursor-pointer select-none',
|
||||
'transition-all duration-150',
|
||||
'focus-visible:outline-none',
|
||||
'disabled:opacity-40 disabled:cursor-not-allowed disabled:pointer-events-none',
|
||||
'rounded-none',
|
||||
'whitespace-nowrap',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
EXEC: [
|
||||
'bg-transparent',
|
||||
'border-[var(--color-green)]',
|
||||
'text-[var(--color-green)]',
|
||||
'hover:bg-[var(--color-green)]',
|
||||
'hover:text-[var(--color-bg)]',
|
||||
'hover:shadow-[var(--glow-green)]',
|
||||
'focus-visible:shadow-[var(--glow-green)]',
|
||||
],
|
||||
GHOST: [
|
||||
'bg-transparent',
|
||||
'border-transparent',
|
||||
'text-[var(--text-secondary)]',
|
||||
'hover:text-[var(--color-green)]',
|
||||
'hover:border-[var(--border)]',
|
||||
'focus-visible:border-[var(--border)]',
|
||||
],
|
||||
OUTLINE: [
|
||||
'bg-[var(--surface)]',
|
||||
'border-[var(--border)]',
|
||||
'text-[var(--text-secondary)]',
|
||||
'hover:border-[var(--border-active)]',
|
||||
'hover:text-[var(--color-green)]',
|
||||
'hover:shadow-[var(--glow-green)]',
|
||||
'focus-visible:border-[var(--border-active)]',
|
||||
'focus-visible:shadow-[var(--glow-green)]',
|
||||
],
|
||||
ABORT: [
|
||||
'bg-transparent',
|
||||
'border-[var(--color-red)]',
|
||||
'text-[var(--color-red)]',
|
||||
'hover:bg-[var(--color-red)]',
|
||||
'hover:text-[var(--color-bg)]',
|
||||
'hover:shadow-[var(--glow-red)]',
|
||||
'focus-visible:shadow-[var(--glow-red)]',
|
||||
],
|
||||
},
|
||||
size: {
|
||||
SM: 'h-7 px-3 text-[0.65rem] gap-1.5',
|
||||
MD: 'h-9 px-4 text-[0.75rem] gap-2',
|
||||
LG: 'h-11 px-6 text-[0.875rem] gap-2.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'OUTLINE',
|
||||
size: 'MD',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-[var(--surface)] border border-[var(--border)]',
|
||||
'text-[var(--text-secondary)] font-mono',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col gap-1.5 p-4 border-b border-[var(--border)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-semibold uppercase tracking-widest text-[var(--color-green)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-xs text-[var(--text-muted)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-4', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-4 border-t border-[var(--border)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
|
||||
@@ -0,0 +1,312 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type LineChartVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'
|
||||
|
||||
export interface LineChartSeries {
|
||||
id: string
|
||||
label: string
|
||||
data: number[]
|
||||
variant?: LineChartVariant
|
||||
}
|
||||
|
||||
export interface LineChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
series: LineChartSeries[]
|
||||
labels: string[]
|
||||
title?: string
|
||||
height?: number
|
||||
min?: number
|
||||
max?: number
|
||||
showDots?: boolean
|
||||
showArea?: boolean
|
||||
showGrid?: boolean
|
||||
showLegend?: boolean
|
||||
animated?: boolean
|
||||
}
|
||||
|
||||
const LEFT = 40
|
||||
const RIGHT = 8
|
||||
const TOP = 8
|
||||
const BOTTOM = 24
|
||||
|
||||
const SERIES_COLORS = [
|
||||
'var(--color-green)',
|
||||
'var(--color-amber)',
|
||||
'var(--color-red)',
|
||||
'var(--color-blue)',
|
||||
]
|
||||
|
||||
const VARIANT_COLORS: Record<LineChartVariant, string> = {
|
||||
DEFAULT: 'var(--text-secondary)',
|
||||
ACTIVE: 'var(--color-green)',
|
||||
WARNING: 'var(--color-amber)',
|
||||
CRITICAL: 'var(--color-red)',
|
||||
}
|
||||
|
||||
function seriesColor(s: LineChartSeries, idx: number): string {
|
||||
if (s.variant) return VARIANT_COLORS[s.variant]
|
||||
return SERIES_COLORS[idx % SERIES_COLORS.length]
|
||||
}
|
||||
|
||||
const LineChart = React.forwardRef<HTMLDivElement, LineChartProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
series,
|
||||
labels,
|
||||
title,
|
||||
height = 160,
|
||||
min,
|
||||
max,
|
||||
showDots = true,
|
||||
showArea = false,
|
||||
showGrid = true,
|
||||
showLegend,
|
||||
animated = true,
|
||||
style,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||
const [svgW, setSvgW] = React.useState(500)
|
||||
const [drawn, setDrawn] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const ro = new ResizeObserver(entries => {
|
||||
setSvgW(entries[0].contentRect.width)
|
||||
})
|
||||
ro.observe(containerRef.current)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setDrawn(true))
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [])
|
||||
|
||||
const allValues = series.flatMap(s => s.data)
|
||||
const dataMin = allValues.length > 0 ? Math.min(...allValues) : 0
|
||||
const dataMax = allValues.length > 0 ? Math.max(...allValues) : 100
|
||||
const minV = min ?? dataMin
|
||||
const maxV = max ?? dataMax
|
||||
const range = Math.max(maxV - minV, 1)
|
||||
|
||||
const plotW = svgW - LEFT - RIGHT
|
||||
const plotH = height - TOP - BOTTOM
|
||||
const n = labels.length
|
||||
|
||||
function xPos(i: number) {
|
||||
return LEFT + (n <= 1 ? plotW / 2 : (i / (n - 1)) * plotW)
|
||||
}
|
||||
|
||||
function yPos(v: number) {
|
||||
return TOP + (1 - (v - minV) / range) * plotH
|
||||
}
|
||||
|
||||
function buildLinePath(data: number[]): string {
|
||||
return data
|
||||
.map((v, i) => `${i === 0 ? 'M' : 'L'} ${xPos(i).toFixed(1)} ${yPos(v).toFixed(1)}`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function buildAreaPath(data: number[]): string {
|
||||
if (data.length === 0) return ''
|
||||
const line = buildLinePath(data)
|
||||
const firstX = xPos(0).toFixed(1)
|
||||
const lastX = xPos(data.length - 1).toFixed(1)
|
||||
const bottomY = (TOP + plotH).toFixed(1)
|
||||
return `${line} L ${lastX} ${bottomY} L ${firstX} ${bottomY} Z`
|
||||
}
|
||||
|
||||
const gridFracs = [0, 1 / 3, 2 / 3, 1]
|
||||
const showLeg = showLegend ?? series.length > 1
|
||||
const step = Math.max(1, Math.ceil(n / 8))
|
||||
|
||||
const shouldAnimate = animated
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{title && (
|
||||
<div
|
||||
style={{
|
||||
padding: '0.4rem 0.75rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--surface-raised)',
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={containerRef} style={{ width: '100%', height: `${height}px` }}>
|
||||
<svg width={svgW} height={height} style={{ display: 'block' }}>
|
||||
{/* Grid */}
|
||||
{showGrid && gridFracs.map((f, gi) => {
|
||||
const y = TOP + f * plotH
|
||||
const val = minV + (1 - f) * range
|
||||
return (
|
||||
<g key={gi}>
|
||||
<line
|
||||
x1={LEFT}
|
||||
y1={y.toFixed(1)}
|
||||
x2={(LEFT + plotW).toFixed(1)}
|
||||
y2={y.toFixed(1)}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
<text
|
||||
x={LEFT - 4}
|
||||
y={y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
fontSize={9}
|
||||
fill="var(--text-muted)"
|
||||
fontFamily="var(--font-mono)"
|
||||
>
|
||||
{val.toFixed(0)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* X-axis labels */}
|
||||
{labels.map((label, i) => {
|
||||
if (i % step !== 0 && i !== n - 1) return null
|
||||
return (
|
||||
<text
|
||||
key={i}
|
||||
x={xPos(i)}
|
||||
y={TOP + plotH + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={9}
|
||||
fill="var(--text-muted)"
|
||||
fontFamily="var(--font-mono)"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Series */}
|
||||
{series.map((s, si) => {
|
||||
if (s.data.length === 0) return null
|
||||
const color = seriesColor(s, si)
|
||||
const linePath = buildLinePath(s.data)
|
||||
const areaPath = buildAreaPath(s.data)
|
||||
|
||||
return (
|
||||
<g key={s.id}>
|
||||
{/* Area fill */}
|
||||
{showArea && (
|
||||
<path d={areaPath} fill={color} opacity={0.1} />
|
||||
)}
|
||||
|
||||
{/* Glow layer */}
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={6}
|
||||
opacity={0.12}
|
||||
pathLength={100}
|
||||
strokeDasharray={100}
|
||||
strokeDashoffset={shouldAnimate ? (drawn ? 0 : 100) : 0}
|
||||
style={shouldAnimate ? { transition: 'stroke-dashoffset 0.8s ease' } : undefined}
|
||||
/>
|
||||
|
||||
{/* Main line */}
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
pathLength={100}
|
||||
strokeDasharray={100}
|
||||
strokeDashoffset={shouldAnimate ? (drawn ? 0 : 100) : 0}
|
||||
style={shouldAnimate ? { transition: 'stroke-dashoffset 0.8s ease' } : undefined}
|
||||
/>
|
||||
|
||||
{/* Dots */}
|
||||
{showDots && s.data.map((v, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={xPos(i)}
|
||||
cy={yPos(v)}
|
||||
r={3}
|
||||
fill={color}
|
||||
opacity={shouldAnimate ? (drawn ? 1 : 0) : 1}
|
||||
style={shouldAnimate ? { transition: 'opacity 0.8s ease 0.4s' } : undefined}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
{showLeg && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.75rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--surface-raised)',
|
||||
}}
|
||||
>
|
||||
{series.map((s, si) => {
|
||||
const color = seriesColor(s, si)
|
||||
return (
|
||||
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '0.5rem',
|
||||
height: '0.5rem',
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
LineChart.displayName = 'LineChart'
|
||||
|
||||
export { LineChart }
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/* ── Panel (outer container) ── */
|
||||
export interface PanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
notch?: 'sm' | 'md' | 'lg' | 'none'
|
||||
}
|
||||
|
||||
const Panel = React.forwardRef<HTMLDivElement, PanelProps>(
|
||||
({ className, notch = 'md', style, ...props }, ref) => {
|
||||
const clipMap = {
|
||||
sm: 'var(--clip-corner-sm)',
|
||||
md: 'var(--clip-corner-md)',
|
||||
lg: 'var(--clip-corner-lg)',
|
||||
none: 'none',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-none', className)}
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
clipPath: clipMap[notch],
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Panel.displayName = 'Panel'
|
||||
|
||||
/* ── PanelHeader ── */
|
||||
const PanelHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, style, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0.6rem 1rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--surface-raised)',
|
||||
gap: '0.5rem',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
PanelHeader.displayName = 'PanelHeader'
|
||||
|
||||
/* ── PanelTitle ── */
|
||||
const PanelTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, style, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
style={{
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
margin: 0,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
PanelTitle.displayName = 'PanelTitle'
|
||||
|
||||
/* ── PanelContent ── */
|
||||
const PanelContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, style, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
style={{ padding: '1rem', ...style }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
PanelContent.displayName = 'PanelContent'
|
||||
|
||||
/* ── PanelFooter ── */
|
||||
const PanelFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, style, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0.6rem 1rem',
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--surface-raised)',
|
||||
gap: '0.5rem',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
PanelFooter.displayName = 'PanelFooter'
|
||||
|
||||
export { Panel, PanelHeader, PanelTitle, PanelContent, PanelFooter }
|
||||
@@ -0,0 +1,158 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type ProgressRingVariant = 'DEFAULT' | 'ACTIVE' | 'WARNING' | 'CRITICAL'
|
||||
|
||||
export interface ProgressRingProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
label?: string
|
||||
showValue?: boolean
|
||||
variant?: ProgressRingVariant
|
||||
}
|
||||
|
||||
const ProgressRing = React.forwardRef<HTMLDivElement, ProgressRingProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
value = 0,
|
||||
size = 120,
|
||||
strokeWidth = 6,
|
||||
label,
|
||||
showValue = true,
|
||||
variant = 'DEFAULT',
|
||||
style,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const pct = Math.min(Math.max(value, 0), 100)
|
||||
const cx = size / 2
|
||||
const cy = size / 2
|
||||
const r = (size - strokeWidth) / 2
|
||||
const circ = 2 * Math.PI * r
|
||||
const offset = circ * (1 - pct / 100)
|
||||
|
||||
const accentColor =
|
||||
variant === 'ACTIVE' ? 'var(--color-green)' :
|
||||
variant === 'WARNING' ? 'var(--color-amber)' :
|
||||
variant === 'CRITICAL' ? 'var(--color-red)' :
|
||||
'var(--text-secondary)'
|
||||
|
||||
const textGlow =
|
||||
variant === 'ACTIVE' ? 'var(--text-glow-green)' :
|
||||
variant === 'WARNING' ? 'var(--text-glow-amber)' :
|
||||
variant === 'CRITICAL' ? 'var(--text-glow-red)' :
|
||||
'none'
|
||||
|
||||
const isSmall = size < 80
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: size,
|
||||
height: size,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
flexShrink: 0,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Track ring */}
|
||||
<circle
|
||||
cx={cx} cy={cy} r={r}
|
||||
fill="none"
|
||||
stroke="var(--border)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
|
||||
{/* Glow layer — wider, low opacity */}
|
||||
{pct > 0 && (
|
||||
<circle
|
||||
cx={cx} cy={cy} r={r}
|
||||
fill="none"
|
||||
stroke={accentColor}
|
||||
strokeWidth={strokeWidth + 8}
|
||||
strokeDasharray={circ}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="butt"
|
||||
transform={`rotate(-90 ${cx} ${cy})`}
|
||||
opacity={0.12}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main arc */}
|
||||
<circle
|
||||
cx={cx} cy={cy} r={r}
|
||||
fill="none"
|
||||
stroke={accentColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circ}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="butt"
|
||||
transform={`rotate(-90 ${cx} ${cy})`}
|
||||
style={{ transition: 'stroke-dashoffset 0.4s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Center label */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.1rem',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{showValue && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: isSmall ? '0.7rem' : '1.2rem',
|
||||
fontWeight: 700,
|
||||
color: accentColor,
|
||||
textShadow: textGlow,
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{Math.round(pct)}%
|
||||
</span>
|
||||
)}
|
||||
{label && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: isSmall ? '0.45rem' : '0.55rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
textAlign: 'center',
|
||||
maxWidth: r * 1.2,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ProgressRing.displayName = 'ProgressRing'
|
||||
|
||||
export { ProgressRing }
|
||||
@@ -0,0 +1,146 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const statCardVariants = cva(
|
||||
[
|
||||
'relative flex flex-col font-mono',
|
||||
'border border-solid rounded-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
DEFAULT: ['border-[var(--border)]'],
|
||||
ACTIVE: ['border-[var(--color-green)]'],
|
||||
WARNING: ['border-[var(--color-amber)]'],
|
||||
CRITICAL: ['border-[var(--color-red)]'],
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'DEFAULT' },
|
||||
}
|
||||
)
|
||||
|
||||
export interface StatCardProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof statCardVariants> {
|
||||
label: string
|
||||
value: string | number
|
||||
/** Delta text, e.g. "+2.3%" */
|
||||
delta?: string
|
||||
/** true = positive (▲ green), false = negative (▼ red), undefined = neutral */
|
||||
deltaPositive?: boolean
|
||||
/** Small status line shown at the bottom of the card */
|
||||
sublabel?: string
|
||||
}
|
||||
|
||||
const StatCard = React.forwardRef<HTMLDivElement, StatCardProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant = 'DEFAULT',
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
deltaPositive,
|
||||
sublabel,
|
||||
style,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const accentColor =
|
||||
variant === 'ACTIVE' ? 'var(--color-green)' :
|
||||
variant === 'WARNING' ? 'var(--color-amber)' :
|
||||
variant === 'CRITICAL' ? 'var(--color-red)' :
|
||||
'var(--text-secondary)'
|
||||
|
||||
const accentGlow =
|
||||
variant === 'ACTIVE' ? 'var(--text-glow-green)' :
|
||||
variant === 'WARNING' ? 'var(--text-glow-amber)' :
|
||||
variant === 'CRITICAL' ? 'var(--text-glow-red)' :
|
||||
'none'
|
||||
|
||||
const deltaColor =
|
||||
deltaPositive === true ? 'var(--color-green)' :
|
||||
deltaPositive === false ? 'var(--color-red)' :
|
||||
'var(--text-muted)'
|
||||
|
||||
const deltaSymbol =
|
||||
deltaPositive === true ? '▲ ' :
|
||||
deltaPositive === false ? '▼ ' :
|
||||
'— '
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(statCardVariants({ variant }), className)}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
gap: '0.4rem',
|
||||
background: 'var(--surface)',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{/* Label */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '2rem',
|
||||
fontWeight: 700,
|
||||
color: accentColor,
|
||||
textShadow: accentGlow,
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
|
||||
{/* Delta */}
|
||||
{delta !== undefined && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.7rem',
|
||||
color: deltaColor,
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{deltaSymbol}{delta}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sublabel */}
|
||||
{sublabel && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
marginTop: 'auto',
|
||||
paddingTop: '0.6rem',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{sublabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
StatCard.displayName = 'StatCard'
|
||||
|
||||
export { StatCard, statCardVariants }
|
||||
@@ -0,0 +1,158 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type SystemStatus = 'ACTIVE' | 'OFFLINE' | 'WARNING' | 'CRITICAL' | 'SCANNING'
|
||||
|
||||
export interface SystemEntry {
|
||||
name: string
|
||||
status: SystemStatus
|
||||
/** Optional secondary text shown beside the name */
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface StatusGridProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
systems: SystemEntry[]
|
||||
title?: string
|
||||
columns?: 1 | 2 | 3
|
||||
}
|
||||
|
||||
function statusColor(s: SystemStatus): string {
|
||||
switch (s) {
|
||||
case 'ACTIVE': return 'var(--color-green)'
|
||||
case 'OFFLINE': return 'var(--text-muted)'
|
||||
case 'WARNING': return 'var(--color-amber)'
|
||||
case 'CRITICAL': return 'var(--color-red)'
|
||||
case 'SCANNING': return 'var(--color-green)'
|
||||
}
|
||||
}
|
||||
|
||||
function statusGlow(s: SystemStatus): string {
|
||||
switch (s) {
|
||||
case 'ACTIVE': return 'var(--glow-green)'
|
||||
case 'SCANNING': return 'var(--glow-green)'
|
||||
case 'WARNING': return 'var(--glow-amber)'
|
||||
case 'CRITICAL': return 'var(--glow-red)'
|
||||
default: return 'none'
|
||||
}
|
||||
}
|
||||
|
||||
const StatusGrid = React.forwardRef<HTMLDivElement, StatusGridProps>(
|
||||
({ className, systems, title, columns = 1, style, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{/* Header */}
|
||||
{title && (
|
||||
<div
|
||||
style={{
|
||||
padding: '0.4rem 0.75rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--surface-raised)',
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid of rows */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{systems.map((system, i) => {
|
||||
const color = statusColor(system.status)
|
||||
const glow = statusGlow(system.status)
|
||||
const isScanning = system.status === 'SCANNING'
|
||||
|
||||
// border logic: no bottom border on last row(s), no right on last column
|
||||
const totalRows = Math.ceil(systems.length / columns)
|
||||
const rowIndex = Math.floor(i / columns)
|
||||
const colIndex = i % columns
|
||||
const isLastRow = rowIndex === totalRows - 1
|
||||
const isLastCol = colIndex === columns - 1
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderBottom: isLastRow ? 'none' : '1px solid var(--border)',
|
||||
borderRight: isLastCol ? 'none' : '1px solid var(--border)',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{/* Name + optional detail */}
|
||||
<span
|
||||
style={{
|
||||
minWidth: 0,
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--text-secondary)',
|
||||
letterSpacing:'0.06em',
|
||||
textTransform:'uppercase',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{system.name}
|
||||
{system.detail && (
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: '0.5rem', textTransform: 'none' }}>
|
||||
{system.detail}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Status dot + label */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
boxShadow: glow,
|
||||
animation: isScanning ? 'blink-cursor 1s step-end infinite' : undefined,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
color,
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{system.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
StatusGrid.displayName = 'StatusGrid'
|
||||
|
||||
export { StatusGrid }
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as React from 'react'
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
const TabsGroup = TabsPrimitive.List // alias
|
||||
|
||||
/* ── List ── */
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-end',
|
||||
'border-b border-[var(--border)]',
|
||||
'w-full gap-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
/* ── Trigger ── */
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center',
|
||||
'px-4 py-2',
|
||||
'text-[0.7rem] font-mono font-medium uppercase tracking-widest',
|
||||
'border-b-2 border-transparent',
|
||||
'-mb-px',
|
||||
'text-[var(--text-muted)]',
|
||||
'cursor-pointer',
|
||||
'outline-none',
|
||||
'transition-all duration-150',
|
||||
'hover:text-[var(--text-secondary)]',
|
||||
'data-[state=active]:text-[var(--color-green)]',
|
||||
'data-[state=active]:border-b-[var(--color-green)]',
|
||||
'data-[state=active]:[text-shadow:var(--text-glow-green)]',
|
||||
'disabled:pointer-events-none disabled:opacity-40',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
/* ── Content ── */
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-4',
|
||||
'focus-visible:outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsGroup, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type TerminalLineType = 'input' | 'output' | 'warn' | 'error' | 'system'
|
||||
|
||||
export interface TerminalLine {
|
||||
type?: TerminalLineType
|
||||
text: string
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
export interface TerminalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
lines?: TerminalLine[]
|
||||
prompt?: string
|
||||
title?: string
|
||||
height?: string | number
|
||||
blinkCursor?: boolean
|
||||
}
|
||||
|
||||
function lineColor(type: TerminalLineType = 'output'): string {
|
||||
switch (type) {
|
||||
case 'input': return 'var(--color-green)'
|
||||
case 'output': return 'var(--text-secondary)'
|
||||
case 'warn': return 'var(--color-amber)'
|
||||
case 'error': return 'var(--color-red)'
|
||||
case 'system': return 'var(--text-muted)'
|
||||
}
|
||||
}
|
||||
|
||||
function linePrefix(type: TerminalLineType = 'output'): string {
|
||||
switch (type) {
|
||||
case 'input': return '>'
|
||||
case 'output': return ' '
|
||||
case 'warn': return '⚠'
|
||||
case 'error': return '✕'
|
||||
case 'system': return '#'
|
||||
}
|
||||
}
|
||||
|
||||
const Terminal = React.forwardRef<HTMLDivElement, TerminalProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
lines = [],
|
||||
prompt = '>',
|
||||
title = 'TERMINAL',
|
||||
height = '16rem',
|
||||
blinkCursor = true,
|
||||
style,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const bodyRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (bodyRef.current) {
|
||||
bodyRef.current.scrollTop = bodyRef.current.scrollHeight
|
||||
}
|
||||
}, [lines])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{/* Title bar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.4rem 0.75rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--surface-raised)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--color-green)',
|
||||
boxShadow: 'var(--glow-green)',
|
||||
animation: 'blink-cursor 2s step-end infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Output body */}
|
||||
<div
|
||||
ref={bodyRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '0.75rem',
|
||||
height,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.2rem',
|
||||
}}
|
||||
>
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: 1.6,
|
||||
color: lineColor(line.type),
|
||||
}}
|
||||
>
|
||||
{line.timestamp && (
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--text-muted)',
|
||||
flexShrink: 0,
|
||||
fontSize: '0.65rem',
|
||||
}}
|
||||
>
|
||||
{line.timestamp}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ flexShrink: 0, opacity: 0.5, userSelect: 'none' }}>
|
||||
{linePrefix(line.type)}
|
||||
</span>
|
||||
<span style={{ wordBreak: 'break-all' }}>{line.text}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Blinking cursor */}
|
||||
{blinkCursor && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '0.75rem',
|
||||
marginTop: '0.1rem',
|
||||
gap: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--text-muted)', userSelect: 'none' }}>{prompt}</span>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '0.55rem',
|
||||
height: '1em',
|
||||
background: 'var(--color-green)',
|
||||
boxShadow: 'var(--glow-green)',
|
||||
animation: 'blink-cursor 1s step-end infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Terminal.displayName = 'Terminal'
|
||||
|
||||
export { Terminal }
|
||||
@@ -0,0 +1,29 @@
|
||||
# Isolated e2e stack for CI (.gitea/workflows/ci.yml) — runs on the runner's shared dind
|
||||
# daemon under project name space-wanderer-ci (distinct from production). Joins the external
|
||||
# network space-wanderer-ci-e2e that the workflow attaches the job container to; the app is reached
|
||||
# at its STATIC IP http://172.39.0.10:3000 — DNS bypassed for the browser because Chromium
|
||||
# got ERR_SSL_PROTOCOL_ERROR via the hostname while node-side HTTP worked (the `app` alias
|
||||
# stays as a convenience). Single deterministic address; survives `docker compose restart space-wanderer`.
|
||||
# The build context is tarred by the compose client in the job and sent over DOCKER_HOST,
|
||||
# so nothing here needs to be visible from inside dind; game state lives in a named volume
|
||||
# (fresh per run — CI cleanup removes it — survives `docker compose restart space-wanderer` for
|
||||
# e2e/state.spec.ts).
|
||||
services:
|
||||
space-wanderer:
|
||||
build: .
|
||||
image: space-wanderer-ci
|
||||
volumes:
|
||||
- space-wanderer-db:/app/db
|
||||
networks:
|
||||
space-wanderer-ci-e2e:
|
||||
aliases:
|
||||
- app
|
||||
ipv4_address: 172.39.0.10
|
||||
|
||||
networks:
|
||||
space-wanderer-ci-e2e:
|
||||
name: space-wanderer-ci-e2e
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
space-wanderer-db:
|
||||
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
space-wanderer:
|
||||
build: .
|
||||
volumes:
|
||||
# Game state SQLite: created on first run, must survive rebuilds.
|
||||
- "./db:/app/db"
|
||||
networks:
|
||||
- dev_net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.space-wanderer.rule=Host(`space-wanderer.dev.kkhost.pl`)"
|
||||
- "traefik.http.routers.space-wanderer.entrypoints=web"
|
||||
- "traefik.http.services.space-wanderer.loadbalancer.server.port=3000"
|
||||
|
||||
networks:
|
||||
dev_net:
|
||||
external: true
|
||||
@@ -0,0 +1,36 @@
|
||||
// CI-only connectivity diagnostics (globalSetup runs after the e2e stack is ready).
|
||||
// Evidence for the Chromium ERR_SSL_PROTOCOL_ERROR hunt: compares node-side resolution/HTTP
|
||||
// against Chromium's own navigation, hostname vs static IP. Never fails the run.
|
||||
import { promises as dnsPromises } from 'node:dns';
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
export default async function ciDiagnostics() {
|
||||
if (process.env.PW_REMOTE_BROWSER !== '0') return; // local runs: no-op
|
||||
const base = process.env.E2E_BASE_URL ?? '';
|
||||
try {
|
||||
console.log('[diag] node resolve4(app):', JSON.stringify(await dnsPromises.resolve4('app').catch((e) => `failed: ${e.code}`)));
|
||||
try {
|
||||
const r = await fetch(`${base}/api/version`);
|
||||
console.log('[diag] node fetch', base, '->', r.status, JSON.stringify(await r.json().catch(() => null)));
|
||||
} catch (e) {
|
||||
console.log('[diag] node fetch failed:', String(e));
|
||||
}
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const url of ['http://app:3000/', base]) {
|
||||
const page = await (await browser.newContext()).newPage();
|
||||
try {
|
||||
await page.goto(url, { timeout: 15_000 });
|
||||
console.log('[diag] chromium goto', url, '-> OK, title:', await page.title().catch(() => '?'));
|
||||
} catch (e) {
|
||||
console.log('[diag] chromium goto', url, '-> FAILED:', String(e).split('\n')[0]);
|
||||
}
|
||||
await page.context().close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[diag] diagnostics error (non-fatal):', String(e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('landing page loads with the SpaceWanderer title', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('mission control layout renders scificn-ui components', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Stat card, tab trigger and footer badge prove the copied component layer is wired in.
|
||||
await expect(page.getByText('NEXT ENCOUNTER')).toBeVisible();
|
||||
// Radix Tabs.Trigger renders role="tab", not a plain button.
|
||||
await expect(page.getByRole('tab', { name: 'SYSTEMS' })).toBeVisible();
|
||||
await expect(page.getByText('SYS ONLINE')).toBeVisible();
|
||||
});
|
||||
|
||||
test('operations tabs switch panels client-side', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('tab', { name: 'LOG' }).click();
|
||||
await expect(page.getByText('SHIP LOG // PLACEHOLDER FEED')).toBeVisible();
|
||||
});
|
||||
|
||||
test('GET /api/version reports the app version as JSON', async ({ page }) => {
|
||||
const res = await page.request.get('/api/version');
|
||||
expect(res.status()).toBe(200);
|
||||
expect(res.headers()['content-type']).toContain('application/json');
|
||||
const body = (await res.json()) as { version: string };
|
||||
expect(body.version).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// Repo root — docker-compose.yml lives here; `docker compose restart` runs on
|
||||
// this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the
|
||||
// isolated stack file + project (-f docker-compose.ci.yml -p space-wanderer-ci) instead of the default one.
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
|
||||
|
||||
test('app survives a container restart (stack + volume persistence)', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
// Baseline: the app serves and reports its version.
|
||||
const resBefore = await page.request.get('/api/version');
|
||||
expect(resBefore.status()).toBe(200);
|
||||
const before = (await resBefore.json()) as { version: string };
|
||||
expect(before.version).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
|
||||
// Restart the app container mid-run — the named DB volume must survive it.
|
||||
execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' });
|
||||
|
||||
// Wait for the app to come back serving.
|
||||
let after: { version: string } | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
try {
|
||||
const res = await page.request.get('/api/version');
|
||||
if (res.status() === 200) after = (await res.json()) as { version: string };
|
||||
return !!after;
|
||||
} catch {
|
||||
return false; // connection refused / 502 while the container starts
|
||||
}
|
||||
}, { timeout: 120_000, message: 'waiting for /api/version after docker compose restart' })
|
||||
.toBe(true);
|
||||
|
||||
expect(after).toBeDefined();
|
||||
expect(after!.version).toBe(before.version); // same build, not a re-seeded stack
|
||||
|
||||
// The page itself serves again with the landing title.
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
// Open the game DB once at server boot (Next ≥15 instrumentation hook; runs under `next dev`
|
||||
// and `next start`) so the ./db volume file exists from first start, before any request.
|
||||
// Dynamic import inside the runtime guard: lib/db/client.ts uses node:fs + sql.js, which must
|
||||
// never be pulled into the Edge Runtime instrumentation bundle (Next bundles register() for both).
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
const { getGameDb } = await import('@/lib/db/client');
|
||||
void getGameDb();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { openGameDb, persist } from './client';
|
||||
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'sw-db-test-'));
|
||||
const file = path.join(dir, 'test.db');
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function metaValue(db: Awaited<ReturnType<typeof openGameDb>>, key: string): string | undefined {
|
||||
const rows = db.raw.exec('SELECT value FROM _meta WHERE key = ?', [key]);
|
||||
return rows[0]?.values[0][0] as string | undefined;
|
||||
}
|
||||
|
||||
describe('db client (sql.js/Drizzle plumbing)', () => {
|
||||
it('creates a valid DB file on disk when opening a fresh path', async () => {
|
||||
const db = await openGameDb(file);
|
||||
expect(db.file).toBe(file);
|
||||
const s = await stat(file);
|
||||
expect(s.size).toBeGreaterThan(0); // persisted at first open, not left in-memory only
|
||||
expect(metaValue(db, 'created_at')).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('preserves state across reopen (volume persistence)', async () => {
|
||||
const created = metaValue(await openGameDb(file), 'created_at');
|
||||
expect(created).toBeDefined();
|
||||
const db = await openGameDb(file); // second process would see the same file
|
||||
expect(db.raw.exec('PRAGMA integrity_check')[0].values[0][0]).toBe('ok');
|
||||
expect(metaValue(db, 'created_at')).toBe(created); // not re-stamped on reopen
|
||||
});
|
||||
|
||||
it('persist() rewrites the file atomically (tmp + rename)', async () => {
|
||||
const db = await openGameDb(file);
|
||||
await persist(db);
|
||||
const s = await stat(file);
|
||||
expect(s.size).toBeGreaterThan(0);
|
||||
// no leftover tmp file after a clean persist
|
||||
let tmpGone = true;
|
||||
try {
|
||||
await stat(`${file}.tmp`);
|
||||
tmpGone = false;
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
expect(tmpGone).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { drizzle, type SQLJsDatabase } from 'drizzle-orm/sql-js';
|
||||
import initSqlJs, { type Database as RawSqlJsDatabase } from 'sql.js';
|
||||
import * as schema from './schema';
|
||||
|
||||
// SQLite via sql.js (WASM) — no native compilation, so it builds and runs in the
|
||||
// Docker image without a toolchain. Trade-off: the DB lives in memory and every write
|
||||
// must be exported to disk; persist() does that atomically (tmp file + rename).
|
||||
|
||||
export interface GameDb {
|
||||
db: SQLJsDatabase<typeof schema>;
|
||||
raw: RawSqlJsDatabase;
|
||||
file: string;
|
||||
}
|
||||
|
||||
// CREATE TABLE statements mirroring lib/db/schema.ts — keep in sync. Game tables land with
|
||||
// the turn engine; _meta is DB plumbing (not game state): a brand-new sql.js database exports
|
||||
// as ZERO bytes, so without at least one real table there would be nothing valid to persist
|
||||
// on the ./db volume from first boot.
|
||||
const DDL = `
|
||||
CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT);
|
||||
`;
|
||||
|
||||
// SPACE_WANDERER_DB_PATH override exists so unit tests can point at a temp file instead
|
||||
// of the real ./db/space-wanderer.db.
|
||||
export function defaultDbPath(): string {
|
||||
return process.env.SPACE_WANDERER_DB_PATH ?? path.join(process.cwd(), 'db', 'space-wanderer.db');
|
||||
}
|
||||
|
||||
async function open(file: string): Promise<GameDb> {
|
||||
const SQL = await initSqlJs();
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
bytes = await fs.readFile(file);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
|
||||
raw.exec(DDL); // no-op for tables that already exist in an older file
|
||||
|
||||
const instance: GameDb = { db: drizzle(raw, { schema }), raw, file };
|
||||
|
||||
// First run: stamp when this DB was created and land the file on disk so the ./db volume
|
||||
// mount has a valid SQLite file to persist from boot (instrumentation.ts opens at server start).
|
||||
if (!bytes) {
|
||||
raw.run('INSERT OR IGNORE INTO _meta (key, value) VALUES (?, ?)', ['created_at', new Date().toISOString()]);
|
||||
await persist(instance);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Durably save the in-memory DB: export bytes, write to a temp file, rename over
|
||||
// the target so a crash mid-write never leaves a truncated DB.
|
||||
export async function persist(instance: GameDb): Promise<void> {
|
||||
const bytes = instance.raw.export();
|
||||
await fs.mkdir(path.dirname(instance.file), { recursive: true });
|
||||
const tmp = `${instance.file}.tmp`;
|
||||
await fs.writeFile(tmp, Buffer.from(bytes));
|
||||
await fs.rename(tmp, instance.file);
|
||||
}
|
||||
|
||||
// Singleton for the app process; openGameDb() is the uncached variant (tests).
|
||||
// Lives on globalThis because Next.js can load this module more than once per process —
|
||||
// instrumentation.ts and route handlers get separate module instances, so a plain `let`
|
||||
// would give each its own in-memory sql.js DB (both persisting to the same file). One world per process.
|
||||
const g = globalThis as unknown as { __spaceWandererGameDb?: Promise<GameDb> };
|
||||
|
||||
export function getGameDb(): Promise<GameDb> {
|
||||
return (g.__spaceWandererGameDb ??= open(defaultDbPath()));
|
||||
}
|
||||
|
||||
export async function openGameDb(file: string): Promise<GameDb> {
|
||||
return open(file);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Drizzle schema for game state. EMPTY on purpose at scaffold stage (v0.1.0) — no game
|
||||
// tables yet; the first turn-engine/ship/crew tables land with the first game systems.
|
||||
// Column names/types here are the source of truth; matching CREATE TABLE statements live
|
||||
// in lib/db/client.ts (DDL) and must stay in sync with this file.
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,27 @@
|
||||
import pkg from '@/package.json';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { openApiSpec } from './openapi';
|
||||
|
||||
describe('OpenAPI spec', () => {
|
||||
it('documents exactly the one public endpoint (scaffold stage)', () => {
|
||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual(['/api/version']);
|
||||
});
|
||||
|
||||
it('keeps info.version in sync with package.json', () => {
|
||||
expect(openApiSpec.info.title).toBe('SpaceWanderer API');
|
||||
expect(openApiSpec.info.version).toBe(pkg.version);
|
||||
});
|
||||
|
||||
it('uses an empty server url so "try it" targets the current origin', () => {
|
||||
expect(openApiSpec.servers).toEqual([{ url: '' }]);
|
||||
});
|
||||
|
||||
it('describes /api/version as { version } with a string version', () => {
|
||||
const schema = openApiSpec.paths['/api/version'].get.responses[200].content['application/json'].schema;
|
||||
expect(schema.$ref).toBe('#/components/schemas/AppVersion');
|
||||
|
||||
const appVersion = openApiSpec.components.schemas.AppVersion.properties;
|
||||
expect(Object.keys(appVersion)).toEqual(['version']);
|
||||
expect(appVersion.version.type).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import pkg from '@/package.json';
|
||||
|
||||
// OpenAPI 3.1 document for the app's public JSON API, kept in code so it can be
|
||||
// served machine-readable at /api/openapi (see AGENTS.md "## API"). Must be updated
|
||||
// whenever an endpoint changes.
|
||||
export const openApiSpec = {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'SpaceWanderer API',
|
||||
description:
|
||||
'JSON API for SpaceWanderer — a single-player turn-based space adventure. Scaffold stage: app metadata only; game endpoints (turn actions, state snapshots) land with the first game systems.',
|
||||
// Sourced from package.json so it stays in sync with every version bump.
|
||||
version: pkg.version,
|
||||
},
|
||||
// Empty url = current origin (the spec is served from the same host that runs the API).
|
||||
servers: [{ url: '' }],
|
||||
paths: {
|
||||
'/api/version': {
|
||||
get: {
|
||||
summary: 'App version',
|
||||
description: 'The running app version, read from package.json.',
|
||||
operationId: 'getVersion',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Current app version',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/AppVersion' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
AppVersion: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
version: { type: 'string', description: 'Semver of the running build' },
|
||||
},
|
||||
required: ['version'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// sql.js loads its SQLite WASM blob relative to its own dist/ dir at runtime;
|
||||
// bundling it into a .next chunk would break that resolution. Keeping it
|
||||
// external means require('sql.js') hits node_modules (kept in the image).
|
||||
serverExternalPackages: ['sql.js'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "space-wanderer",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"next": "^16.3.5",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"sql.js": "^1.14.2",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"@types/node": "^22.20.3",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/sql.js": "^1.4.11",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
// Browsers run on the shared remote Playwright server by default (AGENTS.md).
|
||||
// PW_REMOTE_BROWSER=0 → local browsers instead (CI, or boxes without LAN access to 192.168.1.4).
|
||||
const remoteBrowser = process.env.PW_REMOTE_BROWSER !== '0';
|
||||
|
||||
if (!process.env.PW_TEST_CONNECT_WS_ENDPOINT && remoteBrowser) {
|
||||
process.env.PW_TEST_CONNECT_WS_ENDPOINT = 'ws://192.168.1.4:3000/';
|
||||
}
|
||||
|
||||
// App under test. Default: the live compose instance behind Traefik, deployed manually
|
||||
// before running the suite (AGENTS.md). When E2E_DEPLOY_CMD is set it runs as a webServer
|
||||
// and the suite waits for baseURL to come up — CI uses this to target an isolated stack
|
||||
// inside dind instead of production. All vars optional; unset = current local behavior.
|
||||
const deployCmd = process.env.E2E_DEPLOY_CMD;
|
||||
const baseUrl = process.env.E2E_BASE_URL ?? 'https://space-wanderer.dev.kkhost.pl';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
globalSetup: './e2e/ci-diagnostics.ts',
|
||||
// Serial execution: e2e/state.spec.ts restarts the compose container mid-run,
|
||||
// which would break any other spec running concurrently in another worker.
|
||||
workers: 1,
|
||||
timeout: 300_000,
|
||||
retries: 1,
|
||||
use: {
|
||||
baseURL: baseUrl,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: deployCmd
|
||||
? { command: deployCmd, url: baseUrl, reuseExistingServer: true, timeout: 600_000 }
|
||||
: undefined,
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
const config = { plugins: { "@tailwindcss/postcss": {} } }
|
||||
export default config
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(process.cwd()) },
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user