v0.1.0 — base app + stack from Flight-Ops, scificn-ui Mission Control interface #1

Merged
kamilkrzywda merged 9 commits from scaffold-base into master 2026-09-28 18:37:08 +02:00
45 changed files with 4103 additions and 6 deletions
+19
View File
@@ -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*
+229
View File
@@ -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
View File
@@ -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/
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
import { openApiSpec } from '@/lib/openapi';
export async function GET() {
return Response.json(openApiSpec);
}
+5
View File
@@ -0,0 +1,5 @@
import pkg from '@/package.json';
export async function GET() {
return Response.json({ version: pkg.version });
}
+369
View File
@@ -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;
}
+6
View File
@@ -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

+15
View File
@@ -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>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { MissionControl } from '@/components/mission-control';
export const metadata = {
title: 'SpaceWanderer',
};
export default function Home() {
return <MissionControl />;
}
+445
View File
@@ -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=="],
}
}
+653
View File
@@ -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>
)
}
+119
View File
@@ -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 }
+83
View File
@@ -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 }
+257
View File
@@ -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 }
+91
View File
@@ -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 }
+73
View File
@@ -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 }
+312
View File
@@ -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 }
+112
View File
@@ -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 }
+158
View File
@@ -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 }
+146
View File
@@ -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 }
+158
View File
@@ -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 }
+72
View File
@@ -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 }
+187
View File
@@ -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 }
+29
View File
@@ -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:
+17
View File
@@ -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
+36
View File
@@ -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));
}
}
+29
View File
@@ -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+$/);
});
+43
View File
@@ -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();
});
+10
View File
@@ -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();
}
}
+51
View File
@@ -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);
});
});
+75
View File
@@ -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);
}
+6
View 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 {};
+27
View File
@@ -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');
});
});
+47
View File
@@ -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'],
},
},
},
};
+6
View File
@@ -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))
}
+7
View File
@@ -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.
+10
View File
@@ -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;
+35
View File
@@ -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"
}
}
+33
View File
@@ -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,
});
+2
View File
@@ -0,0 +1,2 @@
const config = { plugins: { "@tailwindcss/postcss": {} } }
export default config
+41
View File
@@ -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"
]
}
+12
View File
@@ -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'],
},
});