diff --git a/.gitignore b/.gitignore index 85b3fc2..c7fff43 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,13 @@ go.work.sum e2b/ ## Builds -builds/ \ No newline at end of file +builds/ + +## Frontend +frontend/node_modules/ +frontend/.svelte-kit/ +frontend/build/ + +## Dashboard embedded static (built from frontend, not committed) +internal/dashboard/static/* +!internal/dashboard/static/.gitkeep \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 0af543a..34a6bbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,14 +12,16 @@ All commands go through the Makefile. Never use raw `go build` or `go run`. ```bash make build # Build all binaries → builds/ -make build-cp # Control plane only +make build-cp # Control plane only (builds frontend first) make build-agent # Host agent only make build-envd # envd static binary (verified statically linked) +make build-frontend # SvelteKit dashboard → internal/dashboard/static/ make dev # Full local dev: infra + migrate + control plane make dev-infra # Start PostgreSQL + Prometheus + Grafana (Docker) make dev-down # Stop dev infra make dev-cp # Control plane with hot reload (if air installed) +make dev-frontend # Vite dev server with HMR (port 5173) make dev-agent # Host agent (sudo required) make dev-envd # envd in TCP debug mode @@ -62,13 +64,13 @@ envd is a **completely independent Go module**. It is never imported by the main ### Control Plane -**Packages:** `internal/api/`, `internal/admin/`, `internal/auth/`, `internal/scheduler/`, `internal/lifecycle/`, `internal/config/`, `internal/db/` +**Packages:** `internal/api/`, `internal/dashboard/`, `internal/auth/`, `internal/scheduler/`, `internal/lifecycle/`, `internal/config/`, `internal/db/` Startup (`cmd/control-plane/main.go`) wires: config (env vars) → pgxpool → `db.Queries` (sqlc-generated) → Connect RPC client to host agent → `api.Server`. Everything flows through constructor injection. - **API Server** (`internal/api/server.go`): chi router with middleware. Creates handler structs (`sandboxHandler`, `execHandler`, `filesHandler`, etc.) injected with `db.Queries` and the host agent Connect RPC client. Routes under `/v1/sandboxes/*`. - **Reconciler** (`internal/api/reconciler.go`): background goroutine (every 30s) that compares DB records against `agent.ListSandboxes()` RPC. Marks orphaned DB entries as "stopped". -- **Admin UI** at `/admin/` (htmx + basecoat + alpine.js + Go html/template, no SPA, no build step) +- **Dashboard** (SvelteKit + Tailwind + Bits UI, statically built and embedded via `go:embed`, served as catch-all at root) - **Database**: PostgreSQL via pgx/v5. Queries generated by sqlc from `db/queries/sandboxes.sql`. Migrations in `db/migrations/` (goose, plain SQL). - **Config** (`internal/config/config.go`): purely environment variables (`DATABASE_URL`, `CP_LISTEN_ADDR`, `CP_HOST_AGENT_ADDR`), no YAML/file config. @@ -95,6 +97,22 @@ Runs as PID 1 inside the microVM via `wrenn-init.sh` (mounts procfs/sysfs/dev, s - **FilesystemService**: stat/list/mkdir/move/remove/watch files - **Health**: GET `/health` +### Dashboard (Frontend) + +**Directory:** `frontend/` — standalone SvelteKit app (Svelte 5, runes mode) + +- **Stack**: SvelteKit + `adapter-static` + Tailwind CSS v4 + Bits UI (headless accessible components) +- **Package manager**: pnpm +- **Routing**: SvelteKit file-based routing under `frontend/src/routes/` +- **Routing layout**: `/login` and `/signup` at root, authenticated pages under `/dashboard/*` (e.g. `/dashboard/capsules`, `/dashboard/keys`) +- **Build output**: `frontend/build/` → copied to `internal/dashboard/static/` → embedded via `go:embed` into the control plane binary +- **Serving**: `internal/dashboard/dashboard.go` registers a `NotFound` catch-all SPA handler with fallback to `index.html`. API routes (`/v1/*`, `/openapi.yaml`, `/docs`) are registered first and take priority +- **Dev workflow**: `make dev-frontend` runs Vite dev server on port 5173 with HMR. API calls proxy to `http://localhost:8000` +- **Fonts**: Manrope (UI), Instrument Serif (headings), JetBrains Mono (code), Alice (brand wordmark) — all self-hosted via `@fontsource` +- **Dark mode**: class-based (`.dark` on ``) with system preference detection + localStorage persistence + +To add a new page: create `frontend/src/routes/your-page/+page.svelte`. + ### Networking (per sandbox) Each sandbox gets its own Linux network namespace (`ns-{idx}`). Slot index (1-based, up to 65534) determines all addressing: @@ -183,7 +201,7 @@ To add a new query: add it to the appropriate `.sql` file in `db/queries/` → ` - **TAP networking** (not vsock) for host-to-envd communication - **Device-mapper snapshots** for rootfs CoW — shared read-only loop device per base template, per-sandbox sparse CoW file, Firecracker gets `/dev/mapper/wrenn-{id}` - **PostgreSQL** via pgx/v5 + sqlc (type-safe query generation). Goose for migrations (plain SQL, up/down) -- **Admin UI**: htmx + Go html/template + chi router. No SPA, no React, no build step +- **Dashboard**: SvelteKit (Svelte 5, adapter-static) + Tailwind CSS v4 + Bits UI. Built to static files, embedded into the Go binary via `go:embed`, served as catch-all at root - **Lago** for billing (external service, not in this codebase) ## Coding Conventions @@ -214,22 +232,184 @@ The main module (`go.mod`) and envd (`envd/go.mod`) are fully independent. `make ## Web UI Styling -**Wrenn brand:** -Warm earthy developer tool with crafted organic character. - -**Color palette (light/dark):** -Background scale: #f8f6f1 → #f1eeea → #e8e5e0 → #dedbd5 (light); #090b0a → #0f1211 → #151918 → #1b201e → #222826 (dark). Text hierarchy: bright #2c2a26 / body #4a4740 / dim #7a766e / faint #a09b93 (light); #e8e5df / #c8c4bc / #8a867f / #5f5c57 (dark). Sage green brand accent: #5e8c58 (light) / #89a785 (dark), with glow variant rgba(94,140,88,0.08). Borders: #e2dfd9 (light) / #262c2a (dark). Semantic status colors: amber #9e7c2e (warning/building), red #b35544 (error/failed), blue #3d7aac (info/stopped) — each with a color-dim transparent bg variant for badge backgrounds. Destructive: #b35544 light / #c27b6d dark. - -**Typography:** -Four fonts. Manrope (variable, weights 300–700) for all UI labels, nav, body. Instrument Serif (400) for page titles, empty-state headings, large metric values. JetBrains Mono (400/500) for code, env var keys/values, deployment IDs, commit SHAs, log viewer, URL paths. Alice for the sidebar wordmark only. Base body size 14px. Headings: h1 24px serif, h2 20px, h3 18px, h4–h6 11px sans-serif uppercase wide-tracked. Metric card values 34px serif at letter-spacing: -0.08em. Section labels at 0.06–0.07em tracking, weight 550–600. -Spacing: 4px base unit (Tailwind scale). Page content p-8 (32px). Cards p-4–p-5. Sidebar nav items 7px 10px. Consistent, moderate density — functional but not cramped. - -**Borders & depth:** Flat aesthetic — --shadow-sm: 0 0 #0000, no drop shadows. Depth is achieved through background color stepping (bg → bg-3 → bg-4 → bg-5), not shadows. Borders 1px solid in warm muted tones. Corner radii: cards/surfaces 12px, inputs/small buttons 6–8px, avatars 8px, dots 50%. - -**Components:** Active sidebar nav items use a 3px left-border in sage green rather than filled backgrounds, with a sage glow bg (rgba(94,140,88,0.08)). Focus rings are double-ring: 0 0 0 2px background, 0 0 0 4px ring. Status system has four states (Live/sage, Building/amber+pulse, Failed/red, Stopped/faint) each with solid dot + transparent-bg badge pair. Buttons follow ghost → outline → filled hierarchy. Tables wrapped in rounded-xl border. Dialogs via native . Toasts bottom-anchored. - -**Animation:** Crisp 150ms transitions on all interactive elements. Sidebar width 250ms ease. Custom wrenn-pulse keyframe (2.5s ease infinite box-shadow bloom) on live/building status dots. Top-of-page loading bar (h-0.5, sage green) on navigation. - -**Dark mode:** Full support. Very dark near-black-green backgrounds with warm off-white text and desaturated sage accent. Flat (no card shadows). System preference detection + localStorage persistence. - -**Overall feel:** Warm, earthy, semi-flat. Avoids cold grays entirely — palette leans slightly warm/brown-tinted throughout. The serif + mono + geometric sans type stack gives a designed but unfussy developer-tool character. Organic and considered, not sterile. +### Identity + +Warm, confident developer tool with industrial precision and crafted organic character. The feel is sharp and data-forward — not cold or sterile, but not soft either. Think: an engineer's favorite tool, built with care. + +--- + +### Color Palette (Dark Mode) + +**Background scale (6 steps, near-black-green):** +`#0a0c0b` (bg-0, page base) → `#0f1211` (bg-1, sidebar/topbar) → `#141817` (bg-2, cards/surfaces) → `#1a1e1c` (bg-3, hover states/elevated) → `#212624` (bg-4, inputs/avatars) → `#2a302d` (bg-5, active controls) + +**Text hierarchy (5 levels):** +- Bright `#eae7e2` — page titles, metric values, active states +- Primary `#d0cdc6` — body text, nav labels, readable content +- Secondary `#9b9790` — supporting text, inactive nav, descriptions +- Tertiary `#6b6862` — labels, section headers, timestamps +- Muted `#454340` — ghost text, disabled states, grid labels + +**Sage green brand accent (3 tiers + 2 glows):** +- Solid `#5e8c58` — primary accent, buttons, borders, active indicators +- Mid `#89a785` — badges, chart lines, secondary accent +- Bright `#a4c89f` — active nav text, live counts, chart dots +- Glow `rgba(94,140,88,0.07)` — active nav backgrounds, subtle highlights +- Glow Mid `rgba(94,140,88,0.14)` — live badges, status badge backgrounds + +**Borders (2 levels):** +- Default `#1f2321` — card edges, dividers, sidebar borders +- Mid `#2a2f2c` — hover states, interactive borders, stronger separation + +**Semantic status colors:** +- Amber `#d4a73c` — warning, building, countdown timers +- Red `#cf8172` — error, failed, destructive actions +- Blue `#5a9fd4` — info, stopped (use sparingly) + +**Light mode:** (TBD — follow same warm-tinted approach. Background scale from `#f8f6f1` → `#dedbd5`. Text hierarchy inverts. Accent stays `#5e8c58` for solid.) + +--- + +### Typography + +Four fonts, each with a clear role: + +| Font | Role | Weights | Where | +|------|------|---------|-------| +| **Manrope** (variable) | Body, UI | 400–700 | All body text, nav labels, buttons, descriptions, section headers | +| **Instrument Serif** | Display, metrics | 400 | Page titles (h1), large metric values, empty-state headings only | +| **JetBrains Mono** | Code, data | 400–600 | Status bar, time range buttons, search inputs, IDs, commit SHAs, countdown timers, log viewer, URL paths, code blocks | +| **Alice** | Brand wordmark | 400 | Sidebar wordmark only — never used elsewhere | + +**Sizing:** +- Base body: `14px` +- Page title (h1): `24px` serif, `letter-spacing: -0.02em` +- Card metric values: `36px` serif, `letter-spacing: -0.04em` +- Chart inline metric: `30px` serif, `letter-spacing: -0.04em` +- Nav items: `13px` body, weight 500 +- Section/group labels: `11px` body, uppercase, `letter-spacing: 0.06em`, weight 600 +- Chart section labels: `12px` body, uppercase, `letter-spacing: 0.05em`, weight 600 +- Stat cell labels: `11px` body, uppercase, `letter-spacing: 0.05em`, weight 600 +- Badge text: `10px`, uppercase, `letter-spacing: 0.04em`, weight 600 +- Status bar / footer links: `11–12px` mono +- Table headers: `11px` body, uppercase, `letter-spacing: 0.05em`, weight 600, color muted +- Table body cells: `13px` + +**Key rule:** Instrument Serif is reserved exclusively for page-level titles and large numeric values. It provides warmth and character without softness. Everything else uses Manrope (UI) or JetBrains Mono (data/code). + +--- + +### Spacing + +4px base unit (Tailwind scale). Moderate density — functional and confident, never cramped. + +- Page content padding: `24–28px` +- Card/surface internal padding: `18–20px` +- Sidebar width: `230px` +- Sidebar nav item padding: `8px 10px` +- Sidebar brand area: `18px 16px 16px` +- Tab bar items: `10px 16px` +- Topbar: `16px 28px` +- Metric strip cell: `18px 20px` +- Chart header: `18px 20px` +- Chart canvas: `14px 20px 12px` +- Table header cells: `11px 16px` +- Table body cells: `12px 16px` +- Status bar: `6px 28px` +- Between sections (cards): `20–24px` margin-bottom + +--- + +### Borders & Depth + +**Flat aesthetic — no drop shadows.** Depth comes from background color stepping (bg-0 → bg-1 → bg-2 → bg-3), not shadows. `--shadow-sm: 0 0 #0000`. + +- All borders: `1px solid` in warm muted tones +- Corner radii: cards/surfaces `8px`, inputs/buttons `5px`, logo mark `6px`, avatars `5px`, dots `50%` +- Connected metric cells use shared border container with `border-left: 1px solid` between cells (no gap/grid trick) — creates the industrial panel look +- Tables wrapped in `border-radius: 8px` container with overflow hidden + +--- + +### Components + +**Sidebar navigation:** +- Active items use `3px left-border` in sage solid (`#5e8c58`) with accent glow background (`rgba(94,140,88,0.07)`) +- Active text color: accent-bright (`#a4c89f`) +- Icons at `16px`, opacity 0.5 default, 1.0 on active +- Group labels: `11px` uppercase with `0.06em` tracking, muted color + +**Status chip (live indicator):** +- Rounded `8px` border, `bg-2` background, `border-mid` border +- Pulsing dot: `7px`, accent-solid fill, `box-shadow: 0 0 8px rgba(94,140,88,0.5)` with glow animation +- Count in mono at `14px` accent-bright, label in secondary text + +**Live badges (inline):** +- `10px` text, uppercase, `3px` border-radius +- Background: accent-glow-mid (`rgba(94,140,88,0.14)`), text: accent mid +- Includes `5px` pulsing dot with box-shadow + +**Metric strip:** +- 3-column grid, connected cells (single outer border, inner dividers) +- Hover: background steps from bg-2 to bg-3 +- Value: `36px` serif, bright text +- Label: `11px` uppercase, tertiary +- Sub-metadata row with `1px` divider between items + +**Chart cards:** +- `8px` border-radius, bg-2 background, default border +- Header: section label (12px uppercase) + large serif metric + live badge +- Range group: segmented buttons with `1px` borders, mono text, active state uses bg-5 +- Chart area: SVG with `0.5px` grid lines in border color, `10px` mono axis labels in muted +- Data line: `1.5px` accent-solid stroke, `stroke-linejoin: round` +- Area fill: gradient from `rgba(94,140,88,0.28)` → transparent +- Data dot: accent-bright fill, `2.5px` bg-2 stroke, `4px` radius + +**Buttons hierarchy:** +1. Ghost (icon-btn): transparent bg, default border, tertiary color → border-mid + secondary on hover +2. Outline: no bg, border-mid border → accent-solid border + primary text on hover +3. Tool: bg-2 background, default border → border-mid + primary on hover +4. Filled/CTA: accent-solid background, white text → lighter green on hover, subtle `translateY(-1px)` lift + +**Tables:** +- Container: `8px` border-radius, border, overflow hidden +- Header: bg-3 background, `11px` uppercase muted text +- Body: default bg, `1px` border-bottom between rows +- Row hover: bg-3 + +**Empty states:** +- Centered, `72px` vertical padding +- Icon container: `56px` square, bg-3, border-mid border, `8px` radius +- Heading: `20px` serif, bright text +- Description: `13px` body, tertiary text +- CTA button below + +**Inputs:** +- bg-2 background, default border, `5px` radius +- Mono font for search/filter inputs +- Focus: `border-color: accent-solid` (clean single ring, no double-ring) +- Placeholder: muted color + +**Focus rings:** Single accent-solid border-color change on focus. Clean and minimal — no double-ring outlines. + +--- + +### Animation + +- **All interactive transitions:** `150ms ease` +- **Page load / section entrance:** `fadeUp` — `opacity: 0, translateY(6px)` → visible, `0.35s ease`, staggered with `60–80ms` delays between elements +- **Chart data animation:** SVG `` on path `d`, polyline `points`, and circle `cy` — `0.5–0.6s` duration, `0.2–0.35s` begin delay, `fill: freeze` +- **Live status dot:** `glow` keyframe — `2.5s ease infinite` box-shadow bloom from `0 0 6px rgba(94,140,88,0.5)` → `0 0 14px rgba(94,140,88,0.2)` +- **CTA buttons:** subtle `translateY(-1px)` on hover for lift feel + +--- + +### Dark Mode + +Primary and default mode. Very dark near-black-green backgrounds (`#0a0c0b` base) with warm off-white text and desaturated sage accent. Completely flat — no card shadows anywhere. System preference detection + localStorage persistence. + +--- + +### Overall Feel + +Sharp, warm, industrial-confident. Avoids cold grays entirely — palette leans slightly warm/brown-tinted throughout. The serif display type provides organic character and warmth on titles and metrics, while Manrope handles readable UI text and JetBrains Mono anchors the data-forward, developer-tool identity. Connected metric panels, tight chart cards, and uppercase section labels create engineering density without sacrificing readability. The result is a tool that feels crafted and precise — designed by someone who uses developer tools daily. \ No newline at end of file diff --git a/Makefile b/Makefile index 7bce3d5..4a2e0b6 100644 --- a/Makefile +++ b/Makefile @@ -9,10 +9,13 @@ LDFLAGS := -s -w # ═══════════════════════════════════════════════════ # Build # ═══════════════════════════════════════════════════ -.PHONY: build build-cp build-agent build-envd +.PHONY: build build-cp build-agent build-envd build-frontend build: build-cp build-agent build-envd +build-frontend: + cd frontend && pnpm install --frozen-lockfile && pnpm build + build-cp: go build -v -ldflags="$(LDFLAGS)" -o $(GOBIN)/wrenn-cp ./cmd/control-plane @@ -28,7 +31,7 @@ build-envd: # ═══════════════════════════════════════════════════ # Development # ═══════════════════════════════════════════════════ -.PHONY: dev dev-cp dev-agent dev-envd dev-infra dev-down +.PHONY: dev dev-cp dev-agent dev-envd dev-frontend dev-infra dev-down ## One command to start everything for local dev dev: dev-infra migrate-up dev-cp @@ -49,6 +52,9 @@ dev-cp: dev-agent: sudo go run ./cmd/host-agent +dev-frontend: + cd frontend && pnpm dev --port 5173 + dev-envd: cd $(ENVD_DIR) && go run . --debug --listen-tcp :3002 @@ -171,10 +177,12 @@ help: @echo " make dev-infra Start PostgreSQL + Prometheus + Grafana" @echo " make dev-down Stop dev infra" @echo " make dev-cp Control plane (hot reload if air installed)" + @echo " make dev-frontend Vite dev server with HMR (port 5173)" @echo " make dev-agent Host agent (sudo required)" @echo " make dev-envd envd in TCP debug mode" @echo "" @echo " make build Build all binaries → builds/" + @echo " make build-frontend Build SvelteKit dashboard → frontend/build/" @echo " make build-envd Build envd static binary" @echo "" @echo " make migrate-up Apply migrations" diff --git a/README.md b/README.md index 3ea670c..dff1932 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Control plane listens on `CP_LISTEN_ADDR` (default `:8000`). Hosts must be registered with the control plane before they can serve sandboxes. -1. **Create a host record** (via API or admin UI): +1. **Create a host record** (via API or dashboard): ```bash # As an admin (JWT auth) curl -X POST http://localhost:8000/v1/hosts \ diff --git a/cmd/control-plane/main.go b/cmd/control-plane/main.go index aded747..3f52b41 100644 --- a/cmd/control-plane/main.go +++ b/cmd/control-plane/main.go @@ -80,7 +80,7 @@ func main() { slog.Error("CP_PUBLIC_URL must be set when OAuth providers are configured") os.Exit(1) } - callbackURL := strings.TrimRight(cfg.CPPublicURL, "/") + "/v1/auth/oauth/github/callback" + callbackURL := strings.TrimRight(cfg.CPPublicURL, "/") + "/auth/oauth/github/callback" ghProvider := oauth.NewGitHubProvider(cfg.OAuthGitHubClientID, cfg.OAuthGitHubClientSecret, callbackURL) oauthRegistry.Register(ghProvider) slog.Info("registered OAuth provider", "provider", "github") diff --git a/db/queries/api_keys.sql b/db/queries/api_keys.sql index 0580518..7ea9645 100644 --- a/db/queries/api_keys.sql +++ b/db/queries/api_keys.sql @@ -9,6 +9,14 @@ SELECT * FROM team_api_keys WHERE key_hash = $1; -- name: ListAPIKeysByTeam :many SELECT * FROM team_api_keys WHERE team_id = $1 ORDER BY created_at DESC; +-- name: ListAPIKeysByTeamWithCreator :many +SELECT k.id, k.team_id, k.name, k.key_hash, k.key_prefix, k.created_by, k.created_at, k.last_used, + u.email AS creator_email +FROM team_api_keys k +JOIN users u ON u.id = k.created_by +WHERE k.team_id = $1 +ORDER BY k.created_at DESC; + -- name: DeleteAPIKey :exec DELETE FROM team_api_keys WHERE id = $1 AND team_id = $2; diff --git a/db/queries/sandboxes.sql b/db/queries/sandboxes.sql index 33203f6..f2a5d51 100644 --- a/db/queries/sandboxes.sql +++ b/db/queries/sandboxes.sql @@ -13,7 +13,9 @@ SELECT * FROM sandboxes WHERE id = $1 AND team_id = $2; SELECT * FROM sandboxes ORDER BY created_at DESC; -- name: ListSandboxesByTeam :many -SELECT * FROM sandboxes WHERE team_id = $1 ORDER BY created_at DESC; +SELECT * FROM sandboxes +WHERE team_id = $1 AND status NOT IN ('stopped', 'error') +ORDER BY created_at DESC; -- name: ListSandboxesByHostAndStatus :many SELECT * FROM sandboxes diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f694403 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@fontsource-variable/manrope": "^5.2.8", + "@fontsource/alice": "^5.2.8", + "@fontsource/instrument-serif": "^5.2.8", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.1", + "bits-ui": "^2.16.3", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..9f0353e --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1491 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@fontsource-variable/jetbrains-mono': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource-variable/manrope': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource/alice': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource/instrument-serif': + specifier: ^5.2.8 + version: 5.2.8 + '@sveltejs/adapter-static': + specifier: ^3.0.10 + version: 3.0.10(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))) + '@sveltejs/kit': + specifier: ^2.50.2 + version: 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + '@sveltejs/vite-plugin-svelte': + specifier: ^6.2.4 + version: 6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + '@tailwindcss/vite': + specifier: ^4.2.1 + version: 4.2.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + bits-ui: + specifier: ^2.16.3 + version: 2.16.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12) + svelte: + specifier: ^5.51.0 + version: 5.53.12 + svelte-check: + specifier: ^4.4.2 + version: 4.4.5(picomatch@4.0.3)(svelte@5.53.12)(typescript@5.9.3) + tailwindcss: + specifier: ^4.2.1 + version: 4.2.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.1 + version: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1) + +packages: + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@fontsource-variable/jetbrains-mono@5.2.8': + resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==} + + '@fontsource-variable/manrope@5.2.8': + resolution: {integrity: sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==} + + '@fontsource/alice@5.2.8': + resolution: {integrity: sha512-EDpK9aFXsaRKdyZpgFu8d5+zmE07yIaFxqVeKrYQJjdQpEhWDZA+naLflHwQQmMbLMJK3a4X/RAm5MCScT93NA==} + + '@fontsource/instrument-serif@5.2.8': + resolution: {integrity: sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw==} + + '@internationalized/date@3.12.0': + resolution: {integrity: sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@sveltejs/acorn-typescript@1.0.9': + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/adapter-static@3.0.10': + resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + + '@sveltejs/kit@2.55.0': + resolution: {integrity: sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.3.3 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + typescript: + optional: true + + '@sveltejs/vite-plugin-svelte-inspector@5.0.2': + resolution: {integrity: sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0 + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + + '@sveltejs/vite-plugin-svelte@6.2.4': + resolution: {integrity: sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + + '@swc/helpers@0.5.19': + resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} + + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.1': + resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@typescript-eslint/types@8.57.1': + resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bits-ui@2.16.3: + resolution: {integrity: sha512-5hJ5dEhf5yPzkRFcxzgQHScGodeo0gK0MUUXrdLlRHWaBOBGZiacWLG96j/wwFatKwZvouw7q+sn14i0fx3RIg==} + engines: {node: '>=20'} + peerDependencies: + '@internationalized/date': ^3.8.1 + svelte: ^5.33.0 + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.6.4: + resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.2.4: + resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + engines: {node: '>= 12.0.0'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + runed@0.35.1: + resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==} + peerDependencies: + '@sveltejs/kit': ^2.21.0 + svelte: ^5.7.0 + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + set-cookie-parser@3.0.1: + resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + svelte-check@4.4.5: + resolution: {integrity: sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + + svelte-toolbelt@0.10.6: + resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==} + engines: {node: '>=18', pnpm: '>=8.7.0'} + peerDependencies: + svelte: ^5.30.2 + + svelte@5.53.12: + resolution: {integrity: sha512-4x/uk4rQe/d7RhfvS8wemTfNjQ0bJbKvamIzRBfTe2eHHjzBZ7PZicUQrC2ryj83xxEacfA1zHKd1ephD1tAxA==} + engines: {node: '>=18'} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.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 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.2: + resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@fontsource-variable/jetbrains-mono@5.2.8': {} + + '@fontsource-variable/manrope@5.2.8': {} + + '@fontsource/alice@5.2.8': {} + + '@fontsource/instrument-serif@5.2.8': {} + + '@internationalized/date@3.12.0': + dependencies: + '@swc/helpers': 0.5.19 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@polka/url@1.0.0-next.29': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))': + dependencies: + '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + + '@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@standard-schema/spec': 1.1.0 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + '@types/cookie': 0.6.0 + acorn: 8.16.0 + cookie: 0.6.0 + devalue: 5.6.4 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + set-cookie-parser: 3.0.1 + sirv: 3.0.2 + svelte: 5.53.12 + vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1) + optionalDependencies: + typescript: 5.9.3 + + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + obug: 2.1.1 + svelte: 5.53.12 + vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1) + + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + deepmerge: 4.3.1 + magic-string: 0.30.21 + obug: 2.1.1 + svelte: 5.53.12 + vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1) + vitefu: 1.1.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + + '@swc/helpers@0.5.19': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.1 + + '@tailwindcss/oxide-android-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide@4.2.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + + '@tailwindcss/vite@4.2.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1) + + '@types/cookie@0.6.0': {} + + '@types/estree@1.0.8': {} + + '@types/trusted-types@2.0.7': {} + + '@typescript-eslint/types@8.57.1': {} + + acorn@8.16.0: {} + + aria-query@5.3.1: {} + + axobject-query@4.1.0: {} + + bits-ui@2.16.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12): + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/dom': 1.7.6 + '@internationalized/date': 3.12.0 + esm-env: 1.2.2 + runed: 0.35.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12) + svelte: 5.53.12 + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12) + tabbable: 6.4.0 + transitivePeerDependencies: + - '@sveltejs/kit' + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + + cookie@0.6.0: {} + + deepmerge@4.3.1: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devalue@5.6.4: {} + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + esm-env@1.2.2: {} + + esrap@2.2.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.57.1 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + inline-style-parser@0.2.7: {} + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + jiti@2.6.1: {} + + kleur@4.1.5: {} + + lightningcss-android-arm64@1.31.1: + optional: true + + lightningcss-darwin-arm64@1.31.1: + optional: true + + lightningcss-darwin-x64@1.31.1: + optional: true + + lightningcss-freebsd-x64@1.31.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.31.1: + optional: true + + lightningcss-linux-arm64-gnu@1.31.1: + optional: true + + lightningcss-linux-arm64-musl@1.31.1: + optional: true + + lightningcss-linux-x64-gnu@1.31.1: + optional: true + + lightningcss-linux-x64-musl@1.31.1: + optional: true + + lightningcss-win32-arm64-msvc@1.31.1: + optional: true + + lightningcss-win32-x64-msvc@1.31.1: + optional: true + + lightningcss@1.31.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 + + locate-character@3.0.0: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mri@1.2.0: {} + + mrmime@2.0.1: {} + + nanoid@3.3.11: {} + + obug@2.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@4.1.2: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + runed@0.35.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12): + dependencies: + dequal: 2.0.3 + esm-env: 1.2.2 + lz-string: 1.5.0 + svelte: 5.53.12 + optionalDependencies: + '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + set-cookie-parser@3.0.1: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + svelte-check@4.4.5(picomatch@4.0.3)(svelte@5.53.12)(typescript@5.9.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.3) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.53.12 + typescript: 5.9.3 + transitivePeerDependencies: + - picomatch + + svelte-toolbelt@0.10.6(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12): + dependencies: + clsx: 2.1.1 + runed: 0.35.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12) + style-to-object: 1.0.14 + svelte: 5.53.12 + transitivePeerDependencies: + - '@sveltejs/kit' + + svelte@5.53.12: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@types/estree': 1.0.8 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.6.4 + esm-env: 1.2.2 + esrap: 2.2.4 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + + tabbable@6.4.0: {} + + tailwindcss@4.2.1: {} + + tapable@2.3.0: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + totalist@3.0.1: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + + vitefu@1.1.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)): + optionalDependencies: + vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1) + + zimmerframe@1.1.4: {} diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..72fd49b --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,121 @@ +@import 'tailwindcss'; + +@import '@fontsource-variable/manrope'; +@import '@fontsource/instrument-serif/400.css'; +@import '@fontsource-variable/jetbrains-mono'; +@import '@fontsource/alice/400.css'; + +/* + * Wrenn Design Tokens + * Sharp, warm, industrial-confident. Dark-first. + */ + +@theme { + /* Background scale (6 steps, near-black-green) */ + --color-bg-0: #0a0c0b; + --color-bg-1: #0f1211; + --color-bg-2: #141817; + --color-bg-3: #1a1e1c; + --color-bg-4: #212624; + --color-bg-5: #2a302d; + + /* Text hierarchy (5 levels) */ + --color-text-bright: #eae7e2; + --color-text-primary: #d0cdc6; + --color-text-secondary: #9b9790; + --color-text-tertiary: #6b6862; + --color-text-muted: #454340; + + /* Sage green brand accent (3 tiers + 2 glows) */ + --color-accent: #5e8c58; + --color-accent-mid: #89a785; + --color-accent-bright: #a4c89f; + --color-accent-glow: rgba(94, 140, 88, 0.07); + --color-accent-glow-mid: rgba(94, 140, 88, 0.14); + + /* Borders (2 levels) */ + --color-border: #1f2321; + --color-border-mid: #2a2f2c; + + /* Semantic status */ + --color-amber: #d4a73c; + --color-red: #cf8172; + --color-blue: #5a9fd4; + + /* Fonts */ + --font-sans: 'Manrope Variable', system-ui, sans-serif; + --font-serif: 'Instrument Serif', serif; + --font-mono: 'JetBrains Mono Variable', monospace; + --font-brand: 'Alice', serif; + + /* Radii */ + --radius-card: 8px; + --radius-input: 5px; + --radius-button: 5px; + --radius-avatar: 5px; + --radius-logo: 6px; + + /* Shadows — flat aesthetic */ + --shadow-sm: 0 0 #0000; +} + +/* Base styles */ +html { + font-family: var(--font-sans); + font-size: 14px; + color: var(--color-text-primary); + background-color: var(--color-bg-0); +} + +body { + margin: 0; + min-height: 100vh; +} + +/* Selection */ +::selection { + background: rgba(94, 140, 88, 0.25); + color: var(--color-text-bright); +} + +/* Scrollbar — thin, matches dark theme */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--color-bg-4); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-bg-5); +} + +/* Live status dot glow animation */ +@keyframes wrenn-glow { + 0%, + 100% { + box-shadow: 0 0 6px rgba(94, 140, 88, 0.5); + } + 50% { + box-shadow: 0 0 14px rgba(94, 140, 88, 0.2); + } +} + +/* Fade-up entrance animation */ +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..82dd861 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,15 @@ + + + + + + + + + Wrenn + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts new file mode 100644 index 0000000..51b987a --- /dev/null +++ b/frontend/src/lib/api/auth.ts @@ -0,0 +1,37 @@ +export type AuthResponse = { + token: string; + user_id: string; + team_id: string; + email: string; +}; + +export type AuthResult = { ok: true; data: AuthResponse } | { ok: false; error: string }; + +export async function apiLogin(email: string, password: string): Promise { + return authFetch('/api/v1/auth/login', { email, password }); +} + +export async function apiSignup(email: string, password: string): Promise { + return authFetch('/api/v1/auth/signup', { email, password }); +} + +async function authFetch(url: string, body: Record): Promise { + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + const data = await res.json(); + + if (!res.ok) { + const message = data?.error?.message ?? 'Something went wrong'; + return { ok: false, error: message }; + } + + return { ok: true, data: data as AuthResponse }; + } catch { + return { ok: false, error: 'Unable to connect to the server' }; + } +} diff --git a/frontend/src/lib/api/capsules.ts b/frontend/src/lib/api/capsules.ts new file mode 100644 index 0000000..c51737a --- /dev/null +++ b/frontend/src/lib/api/capsules.ts @@ -0,0 +1,66 @@ +import { apiFetch, type ApiResult } from '$lib/api/client'; + +export type Capsule = { + id: string; + status: string; + template: string; + vcpus: number; + memory_mb: number; + timeout_sec: number; + guest_ip?: string; + host_ip?: string; + created_at: string; + started_at?: string; + last_active_at?: string; + last_updated: string; +}; + + +export async function listCapsules(): Promise> { + return apiFetch('GET', '/api/v1/sandboxes'); +} + +export type CreateCapsuleParams = { + template?: string; + vcpus?: number; + memory_mb?: number; + timeout_sec?: number; +}; + +export async function createCapsule(params: CreateCapsuleParams): Promise> { + return apiFetch('POST', '/api/v1/sandboxes', params); +} + +export async function pauseCapsule(id: string): Promise> { + return apiFetch('POST', `/api/v1/sandboxes/${id}/pause`); +} + +export async function resumeCapsule(id: string): Promise> { + return apiFetch('POST', `/api/v1/sandboxes/${id}/resume`); +} + +export async function destroyCapsule(id: string): Promise> { + return apiFetch('DELETE', `/api/v1/sandboxes/${id}`); +} + +export type Snapshot = { + name: string; + type: string; + vcpus?: number; + memory_mb?: number; + size_bytes: number; + created_at: string; +}; + +export async function createSnapshot(sandboxId: string, name?: string): Promise> { + return apiFetch('POST', '/api/v1/snapshots', { sandbox_id: sandboxId, name }); +} + +export async function listSnapshots(typeFilter?: string): Promise> { + const url = typeFilter ? `/api/v1/snapshots?type=${typeFilter}` : '/api/v1/snapshots'; + return apiFetch('GET', url); +} + +export async function deleteSnapshot(name: string): Promise> { + return apiFetch('DELETE', `/api/v1/snapshots/${name}`); +} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..00fa381 --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,24 @@ +import { auth } from '$lib/auth.svelte'; + +export type ApiResult = { ok: true; data: T } | { ok: false; error: string }; + +export async function apiFetch(method: string, path: string, body?: unknown): Promise> { + try { + const headers: Record = { 'Content-Type': 'application/json' }; + if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; + + const res = await fetch(path, { + method, + headers, + body: body ? JSON.stringify(body) : undefined + }); + + if (res.status === 204) return { ok: true, data: undefined as T }; + + const data = await res.json(); + if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' }; + return { ok: true, data: data as T }; + } catch { + return { ok: false, error: 'Unable to connect to the server' }; + } +} diff --git a/frontend/src/lib/api/keys.ts b/frontend/src/lib/api/keys.ts new file mode 100644 index 0000000..55fcae4 --- /dev/null +++ b/frontend/src/lib/api/keys.ts @@ -0,0 +1,26 @@ +import { apiFetch, type ApiResult } from '$lib/api/client'; + +export type APIKey = { + id: string; + team_id: string; + name: string; + key_prefix: string; + created_by: string; + creator_email?: string; + created_at: string; + last_used?: string; + key?: string; // only present immediately after creation +}; + + +export async function listKeys(): Promise> { + return apiFetch('GET', '/api/v1/api-keys'); +} + +export async function createKey(name: string): Promise> { + return apiFetch('POST', '/api/v1/api-keys', { name }); +} + +export async function revokeKey(id: string): Promise> { + return apiFetch('DELETE', `/api/v1/api-keys/${id}`); +} diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts new file mode 100644 index 0000000..86325df --- /dev/null +++ b/frontend/src/lib/auth.svelte.ts @@ -0,0 +1,94 @@ +import { goto } from '$app/navigation'; + +const STORAGE_KEYS = { + token: 'wrenn_token', + userId: 'wrenn_user_id', + teamId: 'wrenn_team_id', + email: 'wrenn_email' +} as const; + +function isTokenExpired(token: string): boolean { + try { + const payload = token.split('.')[1]; + const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); + const { exp } = JSON.parse(decoded); + return Date.now() / 1000 >= exp; + } catch { + return true; + } +} + +function createAuth() { + let token = $state(null); + let userId = $state(null); + let teamId = $state(null); + let email = $state(null); + let initialized = $state(false); + + // Initialize from localStorage synchronously at module load. + if (typeof window !== 'undefined') { + const stored = localStorage.getItem(STORAGE_KEYS.token); + if (stored && !isTokenExpired(stored)) { + token = stored; + userId = localStorage.getItem(STORAGE_KEYS.userId); + teamId = localStorage.getItem(STORAGE_KEYS.teamId); + email = localStorage.getItem(STORAGE_KEYS.email); + } else if (stored) { + // Expired — clean up. + for (const key of Object.values(STORAGE_KEYS)) { + localStorage.removeItem(key); + } + } + initialized = true; + } + + const isAuthenticated = $derived(token !== null && !isTokenExpired(token)); + + return { + get token() { + return token; + }, + get userId() { + return userId; + }, + get teamId() { + return teamId; + }, + get email() { + return email; + }, + get isAuthenticated() { + return isAuthenticated; + }, + get initialized() { + return initialized; + }, + + login(data: { token: string; user_id: string; team_id: string; email: string }) { + token = data.token; + userId = data.user_id; + teamId = data.team_id; + email = data.email; + + localStorage.setItem(STORAGE_KEYS.token, data.token); + localStorage.setItem(STORAGE_KEYS.userId, data.user_id); + localStorage.setItem(STORAGE_KEYS.teamId, data.team_id); + localStorage.setItem(STORAGE_KEYS.email, data.email); + }, + + logout() { + token = null; + userId = null; + teamId = null; + email = null; + + for (const key of Object.values(STORAGE_KEYS)) { + localStorage.removeItem(key); + } + + goto('/login'); + } + }; +} + +export const auth = createAuth(); diff --git a/frontend/src/lib/components/AuthModal.svelte b/frontend/src/lib/components/AuthModal.svelte new file mode 100644 index 0000000..3ab217f --- /dev/null +++ b/frontend/src/lib/components/AuthModal.svelte @@ -0,0 +1,210 @@ + + + + + + + + + + + +
+ +
+ + {title} + + + {subtitle} + +
+ + + + + +
+
+ or +
+
+ + +
+ {#if mode === 'signup'} +
+
+ +
+ +
+ {/if} + +
+
+ +
+ +
+ +
+
+ +
+ + +
+ + {#if mode === 'signin'} +
+ +
+ {/if} + + +
+ + +

+ {switchText} + +

+
+
+
+
+ + diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..0c165e2 --- /dev/null +++ b/frontend/src/lib/components/Sidebar.svelte @@ -0,0 +1,308 @@ + + + + +{#snippet navSection(label: string, items: NavItem[])} +
+ {#if collapsed} + {#if label !== 'Platform'} +
+ {/if} + {:else} +
+ {label} +
+ {/if} + {#each items as item} + {#if isActive(item.href)} + + {#if !collapsed} +
+ {/if} + + {#if !collapsed} + + {item.label} + + {/if} +
+ {:else} + + + {#if !collapsed} + + {item.label} + + {/if} + + {/if} + {/each} +
+{/snippet} + + + diff --git a/frontend/src/lib/components/Toaster.svelte b/frontend/src/lib/components/Toaster.svelte new file mode 100644 index 0000000..1416ec0 --- /dev/null +++ b/frontend/src/lib/components/Toaster.svelte @@ -0,0 +1,24 @@ + + +
+ {#each toast.list as t (t.id)} +
+ {t.message} + +
+ {/each} +
diff --git a/frontend/src/lib/components/icons/IconAudit.svelte b/frontend/src/lib/components/icons/IconAudit.svelte new file mode 100644 index 0000000..dac2ef2 --- /dev/null +++ b/frontend/src/lib/components/icons/IconAudit.svelte @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/lib/components/icons/IconBell.svelte b/frontend/src/lib/components/icons/IconBell.svelte new file mode 100644 index 0000000..9321951 --- /dev/null +++ b/frontend/src/lib/components/icons/IconBell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconBilling.svelte b/frontend/src/lib/components/icons/IconBilling.svelte new file mode 100644 index 0000000..9dd0d9c --- /dev/null +++ b/frontend/src/lib/components/icons/IconBilling.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconBox.svelte b/frontend/src/lib/components/icons/IconBox.svelte new file mode 100644 index 0000000..f96495d --- /dev/null +++ b/frontend/src/lib/components/icons/IconBox.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/icons/IconCapsule.svelte b/frontend/src/lib/components/icons/IconCapsule.svelte new file mode 100644 index 0000000..cacbae9 --- /dev/null +++ b/frontend/src/lib/components/icons/IconCapsule.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/icons/IconChevron.svelte b/frontend/src/lib/components/icons/IconChevron.svelte new file mode 100644 index 0000000..71076d3 --- /dev/null +++ b/frontend/src/lib/components/icons/IconChevron.svelte @@ -0,0 +1,30 @@ + + + diff --git a/frontend/src/lib/components/icons/IconDocs.svelte b/frontend/src/lib/components/icons/IconDocs.svelte new file mode 100644 index 0000000..e91f07c --- /dev/null +++ b/frontend/src/lib/components/icons/IconDocs.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconEye.svelte b/frontend/src/lib/components/icons/IconEye.svelte new file mode 100644 index 0000000..6016257 --- /dev/null +++ b/frontend/src/lib/components/icons/IconEye.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconEyeOff.svelte b/frontend/src/lib/components/icons/IconEyeOff.svelte new file mode 100644 index 0000000..9543dda --- /dev/null +++ b/frontend/src/lib/components/icons/IconEyeOff.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/icons/IconGithub.svelte b/frontend/src/lib/components/icons/IconGithub.svelte new file mode 100644 index 0000000..0f5668e --- /dev/null +++ b/frontend/src/lib/components/icons/IconGithub.svelte @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/lib/components/icons/IconKey.svelte b/frontend/src/lib/components/icons/IconKey.svelte new file mode 100644 index 0000000..3183073 --- /dev/null +++ b/frontend/src/lib/components/icons/IconKey.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/icons/IconLock.svelte b/frontend/src/lib/components/icons/IconLock.svelte new file mode 100644 index 0000000..2a17bec --- /dev/null +++ b/frontend/src/lib/components/icons/IconLock.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconLogout.svelte b/frontend/src/lib/components/icons/IconLogout.svelte new file mode 100644 index 0000000..bd39919 --- /dev/null +++ b/frontend/src/lib/components/icons/IconLogout.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/icons/IconMail.svelte b/frontend/src/lib/components/icons/IconMail.svelte new file mode 100644 index 0000000..13e24f2 --- /dev/null +++ b/frontend/src/lib/components/icons/IconMail.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconMembers.svelte b/frontend/src/lib/components/icons/IconMembers.svelte new file mode 100644 index 0000000..0b21afa --- /dev/null +++ b/frontend/src/lib/components/icons/IconMembers.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/icons/IconMonitor.svelte b/frontend/src/lib/components/icons/IconMonitor.svelte new file mode 100644 index 0000000..3b709e8 --- /dev/null +++ b/frontend/src/lib/components/icons/IconMonitor.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/icons/IconPlus.svelte b/frontend/src/lib/components/icons/IconPlus.svelte new file mode 100644 index 0000000..bacc1ab --- /dev/null +++ b/frontend/src/lib/components/icons/IconPlus.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconSettings.svelte b/frontend/src/lib/components/icons/IconSettings.svelte new file mode 100644 index 0000000..cce4485 --- /dev/null +++ b/frontend/src/lib/components/icons/IconSettings.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/icons/IconSidebar.svelte b/frontend/src/lib/components/icons/IconSidebar.svelte new file mode 100644 index 0000000..7b8e137 --- /dev/null +++ b/frontend/src/lib/components/icons/IconSidebar.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconTemplate.svelte b/frontend/src/lib/components/icons/IconTemplate.svelte new file mode 100644 index 0000000..e7c612e --- /dev/null +++ b/frontend/src/lib/components/icons/IconTemplate.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/icons/IconTool.svelte b/frontend/src/lib/components/icons/IconTool.svelte new file mode 100644 index 0000000..0951b19 --- /dev/null +++ b/frontend/src/lib/components/icons/IconTool.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/icons/IconUsage.svelte b/frontend/src/lib/components/icons/IconUsage.svelte new file mode 100644 index 0000000..b596f59 --- /dev/null +++ b/frontend/src/lib/components/icons/IconUsage.svelte @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/lib/components/icons/IconUser.svelte b/frontend/src/lib/components/icons/IconUser.svelte new file mode 100644 index 0000000..e5c026a --- /dev/null +++ b/frontend/src/lib/components/icons/IconUser.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconX.svelte b/frontend/src/lib/components/icons/IconX.svelte new file mode 100644 index 0000000..b9214f2 --- /dev/null +++ b/frontend/src/lib/components/icons/IconX.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts new file mode 100644 index 0000000..6296641 --- /dev/null +++ b/frontend/src/lib/components/icons/index.ts @@ -0,0 +1,25 @@ +// Re-export all icon components +export { default as IconGithub } from './IconGithub.svelte'; +export { default as IconCapsule } from './IconCapsule.svelte'; +export { default as IconMonitor } from './IconMonitor.svelte'; +export { default as IconTemplate } from './IconTemplate.svelte'; +export { default as IconTool } from './IconTool.svelte'; +export { default as IconKey } from './IconKey.svelte'; +export { default as IconMembers } from './IconMembers.svelte'; +export { default as IconUsage } from './IconUsage.svelte'; +export { default as IconBilling } from './IconBilling.svelte'; +export { default as IconSettings } from './IconSettings.svelte'; +export { default as IconLogout } from './IconLogout.svelte'; +export { default as IconChevron } from './IconChevron.svelte'; +export { default as IconPlus } from './IconPlus.svelte'; +export { default as IconSidebar } from './IconSidebar.svelte'; +export { default as IconMail } from './IconMail.svelte'; +export { default as IconLock } from './IconLock.svelte'; +export { default as IconUser } from './IconUser.svelte'; +export { default as IconX } from './IconX.svelte'; +export { default as IconEye } from './IconEye.svelte'; +export { default as IconEyeOff } from './IconEyeOff.svelte'; +export { default as IconBell } from './IconBell.svelte'; +export { default as IconDocs } from './IconDocs.svelte'; +export { default as IconAudit } from './IconAudit.svelte'; +export { default as IconBox } from './IconBox.svelte'; diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/frontend/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend/src/lib/sidebar.ts b/frontend/src/lib/sidebar.ts new file mode 100644 index 0000000..b034952 --- /dev/null +++ b/frontend/src/lib/sidebar.ts @@ -0,0 +1,5 @@ +export function getInitialCollapsed(): boolean { + return typeof window !== 'undefined' + ? localStorage.getItem('wrenn_sidebar_collapsed') === 'true' + : false; +} diff --git a/frontend/src/lib/toast.svelte.ts b/frontend/src/lib/toast.svelte.ts new file mode 100644 index 0000000..022e73b --- /dev/null +++ b/frontend/src/lib/toast.svelte.ts @@ -0,0 +1,22 @@ +type Toast = { id: string; message: string; type: 'error' | 'success' }; + +let toasts = $state([]); + +export const toast = { + get list() { + return toasts; + }, + error(message: string, duration = 4000) { + const id = Math.random().toString(36).slice(2); + toasts = [...toasts, { id, message, type: 'error' }]; + setTimeout(() => this.dismiss(id), duration); + }, + success(message: string, duration = 3000) { + const id = Math.random().toString(36).slice(2); + toasts = [...toasts, { id, message, type: 'success' }]; + setTimeout(() => this.dismiss(id), duration); + }, + dismiss(id: string) { + toasts = toasts.filter((t) => t.id !== id); + } +}; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..4991f4d --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,8 @@ + + +{@render children()} diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..72b205e --- /dev/null +++ b/frontend/src/routes/+layout.ts @@ -0,0 +1,3 @@ +// Static site generation — all pages prerendered +export const prerender = true; +export const ssr = false; diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts new file mode 100644 index 0000000..081978d --- /dev/null +++ b/frontend/src/routes/+page.ts @@ -0,0 +1,11 @@ +import { redirect } from '@sveltejs/kit'; +import { browser } from '$app/environment'; +import { auth } from '$lib/auth.svelte'; + +export function load() { + if (!browser) return; + if (auth.isAuthenticated) { + redirect(302, '/dashboard'); + } + redirect(302, '/login'); +} diff --git a/frontend/src/routes/auth/github/callback/+page.svelte b/frontend/src/routes/auth/github/callback/+page.svelte new file mode 100644 index 0000000..8383718 --- /dev/null +++ b/frontend/src/routes/auth/github/callback/+page.svelte @@ -0,0 +1,28 @@ + + +
+

Signing you in...

+
diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte new file mode 100644 index 0000000..404b561 --- /dev/null +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -0,0 +1,7 @@ + + +{@render children()} + diff --git a/frontend/src/routes/dashboard/+layout.ts b/frontend/src/routes/dashboard/+layout.ts new file mode 100644 index 0000000..60264bd --- /dev/null +++ b/frontend/src/routes/dashboard/+layout.ts @@ -0,0 +1,10 @@ +import { redirect } from '@sveltejs/kit'; +import { browser } from '$app/environment'; +import { auth } from '$lib/auth.svelte'; + +export function load() { + if (!browser) return; + if (!auth.isAuthenticated) { + redirect(302, '/login'); + } +} diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte new file mode 100644 index 0000000..9078d85 --- /dev/null +++ b/frontend/src/routes/dashboard/+page.svelte @@ -0,0 +1,5 @@ + diff --git a/frontend/src/routes/dashboard/capsules/+page.svelte b/frontend/src/routes/dashboard/capsules/+page.svelte new file mode 100644 index 0000000..edde71b --- /dev/null +++ b/frontend/src/routes/dashboard/capsules/+page.svelte @@ -0,0 +1,874 @@ + + + + + + { if (e.key === 'Escape') openMenuId = null; }} /> + + + Wrenn - Capsules + + +
+ + +
+
+ +
+ +
+
+

+ Capsules +

+

+ Isolated VMs you can start, pause, and snapshot on demand. +

+
+ +
+ +
+ + + + + {runningCount} + concurrent capsules +
+
+
+ + +
+ + +
+
+ + + {#if activeTab === 'stats'} +
+
+ {@render metricCell('Concurrent Capsules', String(runningCount), '5-sec avg', 'limit: 20', true)} + {@render metricCell('Start Rate / Second', '0.000', '5-sec avg', null, true)} + {@render metricCell('Peak Concurrent', String(runningCount), '30-day max', 'limit: 20', false)} +
+ + {@render chartCard('Concurrent Capsules', String(runningCount), 'average')} + {@render chartCard('Start Rate Per Second', '0.000', 'average')} +
+ {:else} +
+ +
+
+ + + + +
+ {filteredCapsules.length} total + +
+ + + + + + + + +
+ + {#if error} +
+ {error} +
+ {/if} + + +
+ +
+
ID
+
Template
+ {@render sortableHeader('CPU', 'vcpus')} + {@render sortableHeader('Memory', 'memory_mb')} + {@render sortableHeader('Idle Timeout', 'timeout_sec')} + {@render sortableHeader('Started', 'started_at')} + {@render sortableHeader('Status', 'status')} +
+ + {#if loading && capsules.length === 0} +
+
+ + + + Loading capsules... +
+
+ {:else if filteredCapsules.length === 0} +
+
+ + + + + +
+

+ No capsules yet +

+

+ Active capsules will appear here. +

+ +
+ {:else} + {#each filteredCapsules as capsule, i (capsule.id)} +
+ +
+ {#if capsule.status === 'running'} + + + + + {:else if capsule.status === 'paused'} + + {:else} + + {/if} + {capsule.id} +
+ + +
+ {capsule.template} +
+ + +
+ {capsule.vcpus} +
+ + +
+ {capsule.memory_mb}MB +
+ + +
+ {capsule.timeout_sec ? `${capsule.timeout_sec}s` : '—'} +
+ + +
+ {formatTime(capsule.started_at)} + {#if capsule.last_active_at} + {timeAgo(capsule.last_active_at)} + {/if} +
+ + +
+ {#if actionLoading === capsule.id} + + + + + + {:else} + + + {/if} +
+
+ {/each} + {/if} +
+
+ {/if} +
+ + +
+
+ + All systems operational +
+
+
+
+ + +{#if openMenuId} + {@const openCapsule = capsules.find((c) => c.id === openMenuId)} + {#if openCapsule} +
+ {#if openCapsule.status === 'running'} + + + {:else if openCapsule.status === 'paused'} + + + {/if} +
+ +
+ {/if} +{/if} + + +{#if showCreateDialog} +
+ +
{ if (!creating) showCreateDialog = false; }} + onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreateDialog = false; }} + >
+ +
+

Launch Capsule

+

Launch a new isolated VM.

+ + {#if createError} +
+ {createError} +
+ {/if} + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+
+{/if} + + +{#if destroyTarget} +
+ +
{ if (!destroying) destroyTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !destroying) destroyTarget = null; }} + >
+ +
+

Destroy Capsule

+

+ This will permanently destroy {destroyTarget.id}. This action cannot be undone. +

+ + {#if destroyError} +
+ {destroyError} +
+ {/if} + +
+ + +
+
+
+{/if} + + +{#snippet sortableHeader(label: string, key: SortKey)} + +{/snippet} + +{#snippet metricCell(label: string, value: string, sublabel: string, extra: string | null, hasBorderRight: boolean)} +
+
+ {label} + + + Live + +
+
{value}
+
+ {sublabel} + {#if extra} + | + {extra} + {/if} +
+
+{/snippet} + +{#snippet chartCard(label: string, value: string, sublabel: string)} +
+
+
+
{label}
+
+ {value} + {sublabel} + + + Live + +
+
+ +
+ {#each ['5m', '1H', '6H', '24H', '30D'] as range, i} + + {/each} +
+
+ +
+
+ 4 + 3 + 2 + 1 + 0 +
+ + + {#each [0, 45, 90, 135, 180] as y} + + {/each} + + + +
+ {#each ['03:01', '03:02', '03:03', '03:04', '03:05'] as t} + {t} + {/each} +
+
+
+{/snippet} diff --git a/frontend/src/routes/dashboard/keys/+page.svelte b/frontend/src/routes/dashboard/keys/+page.svelte new file mode 100644 index 0000000..b13af5c --- /dev/null +++ b/frontend/src/routes/dashboard/keys/+page.svelte @@ -0,0 +1,440 @@ + + + + Wrenn - API Keys + + +
+ + +
+
+ +
+
+
+

+ API Keys +

+

+ Keys authenticate SDK and direct API requests. Treat them like passwords. +

+
+ + +
+ +
+
+ + +
+ {#if error} +
+ {error} +
+ {/if} + + {#if loading} +
+
+ + + + Loading keys... +
+
+ {:else if keys.length === 0} +
+
+ + + +
+

No API keys yet

+

Create a key to authenticate SDK and API requests.

+ +
+ {:else} +
+ +
+
Name / Key
+
Created By
+
Created
+
Last Used
+
+
+ + {#each keys as key, i (key.id)} +
+ +
+ {key.name || '—'} + {key.key_prefix}... +
+ + +
+ {key.creator_email ?? key.created_by} +
+ + +
+ {formatDate(key.created_at)} +
+ + +
+ {#if key.last_used} + + {timeAgo(key.last_used)} + + {:else} + Never + {/if} +
+ + +
+ +
+
+ {/each} +
+ +

+ {keys.length} {keys.length === 1 ? 'key' : 'keys'} total +

+ {/if} +
+
+ + +
+
+ + All systems operational +
+
+
+
+ + +{#if showCreate} +
+ +
{ if (!creating) showCreate = false; }} + onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreate = false; }} + >
+ +
+

New API Key

+

Give your key a name to identify it later.

+ + {#if createError} +
+ {createError} +
+ {/if} + +
+ + { if (e.key === 'Enter' && !creating) handleCreate(); }} + class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 text-[13px] text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)]" + /> +
+ +
+ + +
+
+
+{/if} + + +{#if newKey} +
+ +
{ newKey = null; }} + onkeydown={(e) => { if (e.key === 'Escape') newKey = null; }} + >
+ +
+ +
+ + + + + + Key created successfully +
+ +

{newKey.name || 'API Key'}

+

+ Copy this key now — it won't be shown again. +

+ + +
+
+ + {newKey.key ?? ''} + + +
+
+ + +
+ + + + +

+ Store this key securely. For security reasons, we only show it once and cannot retrieve it later. +

+
+ +
+ +
+
+
+{/if} + + +{#if revokeTarget} +
+ +
{ if (!revoking) revokeTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !revoking) revokeTarget = null; }} + >
+ +
+

Revoke Key

+

+ Revoke {revokeTarget.name || revokeTarget.id}? + Any request using it will stop working immediately. +

+

{revokeTarget.key_prefix}...

+ + {#if revokeError} +
+ {revokeError} +
+ {/if} + +
+ + +
+
+
+{/if} diff --git a/frontend/src/routes/dashboard/snapshots/+page.svelte b/frontend/src/routes/dashboard/snapshots/+page.svelte new file mode 100644 index 0000000..ab595c3 --- /dev/null +++ b/frontend/src/routes/dashboard/snapshots/+page.svelte @@ -0,0 +1,629 @@ + + + + Wrenn - Templates + + + + { + if (e.key === 'Escape') { + if (openDropdownName) { openDropdownName = null; return; } + if (deleting || launching) return; + deleteTarget = null; + launchTarget = null; + } + }} + onclick={(e) => { + if (openDropdownName && !(e.target as Element)?.closest('.split-btn-container')) { + openDropdownName = null; + } + }} +/> + +
+ + +
+
+ +
+
+
+

+ Templates +

+

+ Point-in-time captures and base environments for launching capsules. +

+
+
+ + +
+ + + + + +
+
+ + + {#if pageTab === 'snapshots'} +
+ {#if error} +
+ {error} +
+ {/if} + + {#if loading} +
+
+ + + + Loading snapshots... +
+
+ {:else} + +
+
+ {#each ([['all', 'All'], ['snapshot', 'Snapshots'], ['base', 'Images']] as const) as [val, label]} + + {/each} +
+ + {filteredSnapshots.length} + {filteredSnapshots.length === 1 ? 'snapshot' : 'snapshots'} + +
+ + {#if filteredSnapshots.length === 0} + +
+
+ + + + +
+

+ {emptyHeading(typeFilter)} +

+

+ {emptyDescription(typeFilter)} +

+ {#if typeFilter === 'all' || typeFilter === 'snapshot'} + + Go to Capsules + + + + + + {/if} +
+ {:else} + +
+ +
+
Name
+
Type
+
vCPUs
+
Memory
+
Size
+
Created
+
Actions
+
+ + + {#each filteredSnapshots as snapshot, i (snapshot.name)} +
+ +
+ {snapshot.name} +
+ + +
+ {#if snapshot.type === 'snapshot'} + + + Snapshot + + {:else} + + + Image + + {/if} +
+ + +
+ {#if snapshot.type === 'snapshot' && snapshot.vcpus != null} + {snapshot.vcpus} + {:else} + + {/if} +
+ + +
+ {#if snapshot.type === 'snapshot' && snapshot.memory_mb != null} + {snapshot.memory_mb} MB + {:else} + + {/if} +
+ + +
+ {formatBytes(snapshot.size_bytes)} +
+ + +
+ {timeAgo(snapshot.created_at)} +
+ + +
+
+ + + +
+ + +
+
+
+ {/each} +
+ +

+ {filteredSnapshots.length} {filteredSnapshots.length === 1 ? 'snapshot' : 'snapshots'} + {typeFilter !== 'all' ? `· filtered` : '· total'} +

+ {/if} + {/if} +
+ {/if} +
+ + +
+
+ + All systems operational +
+
+
+
+ + +{#if openDropdownName} + {@const dropdownSnapshot = snapshots.find((s) => s.name === openDropdownName)} + {#if dropdownSnapshot} +
+ +
+ {/if} +{/if} + + +{#if deleteTarget} +
+
{ if (!deleting) deleteTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !deleting) deleteTarget = null; }} + >
+ +
+

Delete Snapshot

+

+ Delete {deleteTarget.name}? + This action cannot be undone. +

+ + {#if deleteTarget.type === 'snapshot'} +
+ + + + +

+ This live capture includes saved memory state. Any capsule relying on it will be unable to resume. +

+
+ {/if} + + {#if deleteError} +
+ {deleteError} +
+ {/if} + +
+ + +
+
+
+{/if} + + +{#if launchTarget} +
+ +
{ if (!launching) launchTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !launching) launchTarget = null; }} + >
+ +
+

Launch Capsule

+

+ Start a new capsule from this template. +

+ + {#if launchError} +
+ {launchError} +
+ {/if} + + +
+ +
+ {#if launchTarget.type === 'snapshot'} + + {:else} + + {/if} + {launchTarget.name} + + {launchTarget.type === 'snapshot' ? 'Snapshot' : 'Image'} + +
+
+ + +
+
+ + {#if launchTarget.type === 'snapshot'} +
+ {launchTarget.vcpus ?? 1} +
+ {:else} + + {/if} +
+ +
+ + {#if launchTarget.type === 'snapshot'} +
+ {launchTarget.memory_mb ?? 512} +
+ {:else} + + {/if} +
+
+ + +
+ + +
+ +
+ + +
+
+
+{/if} diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..1383440 --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,287 @@ + + + + Wrenn — {mode === 'signin' ? 'Sign in' : 'Sign up'} + + +
+ + + + + +
+ +
+ Wrenn + + Wrenn + +
+ +
+ +
+

+ {title} +

+

+ {subtitle} +

+
+ + + + + Continue with GitHub + + + +
+
+ or +
+
+ + +
+
+
+ +
+ +
+ +
+
+ +
+ + +
+ + {#if mode === 'signin'} +
+ +
+ {/if} + + {#if error} +

{error}

+ {/if} + + +
+ + +

+ {switchText} + +

+
+
+
diff --git a/frontend/static/apple-touch-icon.png b/frontend/static/apple-touch-icon.png new file mode 100644 index 0000000..e25ce97 Binary files /dev/null and b/frontend/static/apple-touch-icon.png differ diff --git a/frontend/static/logo.svg b/frontend/static/logo.svg new file mode 100644 index 0000000..26f2ab1 --- /dev/null +++ b/frontend/static/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/static/robots.txt b/frontend/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/frontend/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..935e080 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,19 @@ +import adapter from '@sveltejs/adapter-static'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: 'index.html', + precompress: false + }) + }, + vitePlugin: { + dynamicCompileOptions: ({ filename }) => + filename.includes('node_modules') ? undefined : { runes: true } + } +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..89afaba --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8000', + rewrite: (path) => path.replace(/^\/api/, '') + } + } + } +}); diff --git a/internal/api/handlers_apikeys.go b/internal/api/handlers_apikeys.go index d0e9074..47f65ed 100644 --- a/internal/api/handlers_apikeys.go +++ b/internal/api/handlers_apikeys.go @@ -24,13 +24,15 @@ type createAPIKeyRequest struct { } type apiKeyResponse struct { - ID string `json:"id"` - TeamID string `json:"team_id"` - Name string `json:"name"` - KeyPrefix string `json:"key_prefix"` - CreatedAt string `json:"created_at"` - LastUsed *string `json:"last_used,omitempty"` - Key *string `json:"key,omitempty"` // only populated on Create + ID string `json:"id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + KeyPrefix string `json:"key_prefix"` + CreatedBy string `json:"created_by"` + CreatorEmail string `json:"creator_email,omitempty"` + CreatedAt string `json:"created_at"` + LastUsed *string `json:"last_used,omitempty"` + Key *string `json:"key,omitempty"` // only populated on Create } func apiKeyToResponse(k db.TeamApiKey) apiKeyResponse { @@ -39,6 +41,26 @@ func apiKeyToResponse(k db.TeamApiKey) apiKeyResponse { TeamID: k.TeamID, Name: k.Name, KeyPrefix: k.KeyPrefix, + CreatedBy: k.CreatedBy, + } + if k.CreatedAt.Valid { + resp.CreatedAt = k.CreatedAt.Time.Format(time.RFC3339) + } + if k.LastUsed.Valid { + s := k.LastUsed.Time.Format(time.RFC3339) + resp.LastUsed = &s + } + return resp +} + +func apiKeyWithCreatorToResponse(k db.ListAPIKeysByTeamWithCreatorRow) apiKeyResponse { + resp := apiKeyResponse{ + ID: k.ID, + TeamID: k.TeamID, + Name: k.Name, + KeyPrefix: k.KeyPrefix, + CreatedBy: k.CreatedBy, + CreatorEmail: k.CreatorEmail, } if k.CreatedAt.Valid { resp.CreatedAt = k.CreatedAt.Time.Format(time.RFC3339) @@ -76,7 +98,7 @@ func (h *apiKeyHandler) Create(w http.ResponseWriter, r *http.Request) { func (h *apiKeyHandler) List(w http.ResponseWriter, r *http.Request) { ac := auth.MustFromContext(r.Context()) - keys, err := h.svc.List(r.Context(), ac.TeamID) + keys, err := h.svc.ListWithCreator(r.Context(), ac.TeamID) if err != nil { writeError(w, http.StatusInternalServerError, "db_error", "failed to list API keys") return @@ -84,7 +106,7 @@ func (h *apiKeyHandler) List(w http.ResponseWriter, r *http.Request) { resp := make([]apiKeyResponse, len(keys)) for i, k := range keys { - resp[i] = apiKeyToResponse(k) + resp[i] = apiKeyWithCreatorToResponse(k) } writeJSON(w, http.StatusOK, resp) diff --git a/internal/api/handlers_test_ui.go b/internal/api/handlers_test_ui.go deleted file mode 100644 index f40f183..0000000 --- a/internal/api/handlers_test_ui.go +++ /dev/null @@ -1,625 +0,0 @@ -package api - -import ( - "fmt" - "net/http" -) - -func serveTestUI(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, testUIHTML) -} - -const testUIHTML = ` - - - - -Wrenn Sandbox — Test Console - - - - -

Wrenn Sandbox Test Console not authenticated

- -
- -
-

Authentication

- - - - -
- - - -
-
-
- - -
-

API Keys

- - -
- - -
- -
- - -
- - -
-

Create Sandbox

- - - - - - - - -
- -
-
- - -
-

Create Snapshot

- - - - -
- - -
- -

Snapshots / Templates

-
- -
-
-
- - -
-

Execute Command

- - - - - - -
- -
- -
- - -
-

Activity Log

-
-
- - -
-

Sandboxes

-
- - -
-
-
-
- - - -` diff --git a/internal/api/middleware_auth.go b/internal/api/middleware_auth.go new file mode 100644 index 0000000..f850344 --- /dev/null +++ b/internal/api/middleware_auth.go @@ -0,0 +1,56 @@ +package api + +import ( + "log/slog" + "net/http" + "strings" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" +) + +// requireAPIKeyOrJWT accepts either X-API-Key header or Authorization: Bearer JWT. +// Both stamp TeamID into the request context via auth.AuthContext. +func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Try API key first. + if key := r.Header.Get("X-API-Key"); key != "" { + hash := auth.HashAPIKey(key) + row, err := queries.GetAPIKeyByHash(r.Context(), hash) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid API key") + return + } + + if err := queries.UpdateAPIKeyLastUsed(r.Context(), row.ID); err != nil { + slog.Warn("failed to update api key last_used", "key_id", row.ID, "error", err) + } + + ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{TeamID: row.TeamID}) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // Try JWT bearer token. + if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") { + tokenStr := strings.TrimPrefix(header, "Bearer ") + claims, err := auth.VerifyJWT(jwtSecret, tokenStr) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid or expired token") + return + } + + ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{ + TeamID: claims.TeamID, + UserID: claims.Subject, + Email: claims.Email, + }) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + writeError(w, http.StatusUnauthorized, "unauthorized", "X-API-Key or Authorization: Bearer required") + }) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index eabbff7..3760167 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -49,14 +49,11 @@ func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient, p r.Get("/openapi.yaml", serveOpenAPI) r.Get("/docs", serveDocs) - // Test UI for sandbox lifecycle management. - r.Get("/test", serveTestUI) - // Unauthenticated auth endpoints. r.Post("/v1/auth/signup", authH.Signup) r.Post("/v1/auth/login", authH.Login) - r.Get("/v1/auth/oauth/{provider}", oauthH.Redirect) - r.Get("/v1/auth/oauth/{provider}/callback", oauthH.Callback) + r.Get("/auth/oauth/{provider}", oauthH.Redirect) + r.Get("/auth/oauth/{provider}/callback", oauthH.Callback) // JWT-authenticated: API key management. r.Route("/v1/api-keys", func(r chi.Router) { @@ -66,9 +63,9 @@ func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient, p r.Delete("/{id}", apiKeys.Delete) }) - // API-key-authenticated: sandbox lifecycle. + // Sandbox lifecycle: accepts API key or JWT bearer token. r.Route("/v1/sandboxes", func(r chi.Router) { - r.Use(requireAPIKey(queries)) + r.Use(requireAPIKeyOrJWT(queries, jwtSecret)) r.Post("/", sandbox.Create) r.Get("/", sandbox.List) @@ -87,9 +84,9 @@ func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient, p }) }) - // API-key-authenticated: snapshot / template management. + // Snapshot / template management: accepts API key or JWT bearer token. r.Route("/v1/snapshots", func(r chi.Router) { - r.Use(requireAPIKey(queries)) + r.Use(requireAPIKeyOrJWT(queries, jwtSecret)) r.Post("/", snapshots.Create) r.Get("/", snapshots.List) r.Delete("/{name}", snapshots.Delete) @@ -127,12 +124,6 @@ func (s *Server) Handler() http.Handler { return s.router } -// Router returns the underlying chi router so additional routes (e.g. dashboard) -// can be mounted on it. -func (s *Server) Router() chi.Router { - return s.router -} - func serveOpenAPI(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/yaml") _, _ = w.Write(openapiYAML) diff --git a/internal/auth/apikey.go b/internal/auth/apikey.go index 7e315ee..bc00a31 100644 --- a/internal/auth/apikey.go +++ b/internal/auth/apikey.go @@ -26,10 +26,10 @@ func HashAPIKey(plaintext string) string { return hex.EncodeToString(sum[:]) } -// APIKeyPrefix returns the displayable prefix of an API key (e.g. "wrn_ab12..."). +// APIKeyPrefix returns the first 8 characters of a plaintext API key (e.g. "wrn_ab12"). func APIKeyPrefix(plaintext string) string { - if len(plaintext) > 12 { - return plaintext[:12] + "..." + if len(plaintext) > 10 { + return plaintext[:10] } return plaintext } diff --git a/internal/db/api_keys.sql.go b/internal/db/api_keys.sql.go index 5af21ff..b4f0ffc 100644 --- a/internal/db/api_keys.sql.go +++ b/internal/db/api_keys.sql.go @@ -7,6 +7,8 @@ package db import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const deleteAPIKey = `-- name: DeleteAPIKey :exec @@ -114,6 +116,57 @@ func (q *Queries) ListAPIKeysByTeam(ctx context.Context, teamID string) ([]TeamA return items, nil } +const listAPIKeysByTeamWithCreator = `-- name: ListAPIKeysByTeamWithCreator :many +SELECT k.id, k.team_id, k.name, k.key_hash, k.key_prefix, k.created_by, k.created_at, k.last_used, + u.email AS creator_email +FROM team_api_keys k +JOIN users u ON u.id = k.created_by +WHERE k.team_id = $1 +ORDER BY k.created_at DESC +` + +type ListAPIKeysByTeamWithCreatorRow struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + KeyHash string `json:"key_hash"` + KeyPrefix string `json:"key_prefix"` + CreatedBy string `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + LastUsed pgtype.Timestamptz `json:"last_used"` + CreatorEmail string `json:"creator_email"` +} + +func (q *Queries) ListAPIKeysByTeamWithCreator(ctx context.Context, teamID string) ([]ListAPIKeysByTeamWithCreatorRow, error) { + rows, err := q.db.Query(ctx, listAPIKeysByTeamWithCreator, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAPIKeysByTeamWithCreatorRow + for rows.Next() { + var i ListAPIKeysByTeamWithCreatorRow + if err := rows.Scan( + &i.ID, + &i.TeamID, + &i.Name, + &i.KeyHash, + &i.KeyPrefix, + &i.CreatedBy, + &i.CreatedAt, + &i.LastUsed, + &i.CreatorEmail, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateAPIKeyLastUsed = `-- name: UpdateAPIKeyLastUsed :exec UPDATE team_api_keys SET last_used = NOW() WHERE id = $1 ` diff --git a/internal/db/sandboxes.sql.go b/internal/db/sandboxes.sql.go index 577f1d0..2bc9481 100644 --- a/internal/db/sandboxes.sql.go +++ b/internal/db/sandboxes.sql.go @@ -219,7 +219,9 @@ func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSand } const listSandboxesByTeam = `-- name: ListSandboxesByTeam :many -SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes WHERE team_id = $1 ORDER BY created_at DESC +SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes +WHERE team_id = $1 AND status NOT IN ('stopped', 'error') +ORDER BY created_at DESC ` func (q *Queries) ListSandboxesByTeam(ctx context.Context, teamID string) ([]Sandbox, error) { diff --git a/internal/service/apikey.go b/internal/service/apikey.go index 5dfc5c1..c49ddca 100644 --- a/internal/service/apikey.go +++ b/internal/service/apikey.go @@ -52,6 +52,11 @@ func (s *APIKeyService) List(ctx context.Context, teamID string) ([]db.TeamApiKe return s.DB.ListAPIKeysByTeam(ctx, teamID) } +// ListWithCreator returns all API keys for the team, joined with the creator's email. +func (s *APIKeyService) ListWithCreator(ctx context.Context, teamID string) ([]db.ListAPIKeysByTeamWithCreatorRow, error) { + return s.DB.ListAPIKeysByTeamWithCreator(ctx, teamID) +} + // Delete removes an API key by ID, scoped to the given team. func (s *APIKeyService) Delete(ctx context.Context, keyID, teamID string) error { return s.DB.DeleteAPIKey(ctx, db.DeleteAPIKeyParams{ID: keyID, TeamID: teamID}) diff --git a/internal/service/sandbox.go b/internal/service/sandbox.go index d0b1ee5..83a645d 100644 --- a/internal/service/sandbox.go +++ b/internal/service/sandbox.go @@ -106,7 +106,7 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db. return sb, nil } -// List returns all sandboxes belonging to the given team. +// List returns active sandboxes (excludes stopped/error) belonging to the given team. func (s *SandboxService) List(ctx context.Context, teamID string) ([]db.Sandbox, error) { return s.DB.ListSandboxesByTeam(ctx, teamID) }