diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..bbf164b --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "env": { + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" + }, + "enabledPlugins": { + "frontend-design@claude-plugins-official": true + } +} diff --git a/.gitignore b/.gitignore index 71e9f4c..2260191 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* +data/ diff --git a/asset-lib-plan.md b/asset-lib-plan.md new file mode 100644 index 0000000..21a7591 --- /dev/null +++ b/asset-lib-plan.md @@ -0,0 +1,208 @@ +# Asset Library — Implementation Plan + +## Context + +Asset URL fields in the editor are plain text inputs with a "Browse…" button. Selected files are loaded via blob URLs. This means saved scenes contain blob URLs that break on reload. The roadmap (`asset-lib.md`) calls for a project asset folder, thumbnails, persistent paths, drag-and-drop import, and asset type detection. This plan implements all five features in dependency order. + +--- + +## Phase 1: Project Folder + Persistent Paths + +These are tightly coupled — the project folder provides the root for relative path computation. + +**Key design decision:** Store relative paths (e.g. `textures/brick.png`) directly in ECS components, never blob URLs (when a project folder is open). An `AssetUriResolver` converts relative paths to object URLs at load time for Three.js loaders. + +### 1A. `ProjectFolder` service class + +**New file:** `src/editor/ProjectFolder.ts` + +Wraps `FileSystemDirectoryHandle` (File System Access API). Key methods: +- `open()` — prompt user with `showDirectoryPicker()` +- `close()` — detach the current folder +- `restore()` — retrieve handle from IndexedDB + `requestPermission()` on startup +- `resolveRelativePath(fileHandle)` — uses `handle.resolve()` → `string[]` segments → join with `/` +- `getFile(relativePath)` — walk directory handles, return `File` +- `createObjectURL(relativePath)` — `getFile()` → `URL.createObjectURL()` +- `importFile(file, subfolder?)` — copy a `File` into the project folder, deduplicate names, return relative path. Infers subfolder from type: `textures/`, `models/`, etc. +- `listTree()` — recursive directory scan → `FolderNode | FileNode` tree +- `subscribe / getSnapshot` — for `useSyncExternalStore` + +Handles are persisted to IndexedDB (`fnayr-editor` db, `handles` store) so the folder is restored across sessions. + +### 1B. `AssetUriResolver` + +**New file:** `src/editor/AssetUriResolver.ts` + +Bridges relative paths → loadable URLs for Three.js loaders: +- `isRelativePath(uri)` — returns true if not `http://`, `blob:`, `data:` +- `resolve(uri)` — relative paths go through `ProjectFolder.createObjectURL()`, absolutes pass through. Caches with refcount. +- `release(uri)` — decrement refcount, revoke when 0 + +### 1C. Wire into editor session + +**Modify:** `src/editor/setup.ts` +- Create `ProjectFolder` + `AssetUriResolver`, call `projectFolder.restore()` +- Set `assetManager.uriResolver = (uri) => resolver.resolve(uri)` +- Add to `EditorSession` type, expose via `__editor` + +**Modify:** `src/engine/assets/manager.ts` +- Add optional `uriResolver?: (uri: string) => Promise` field +- In `request()`, resolve URI before passing to loader + +**Modify:** `src/editor/EditorContext.tsx` — add `projectFolder` to `EditorContextValue` +**Modify:** `src/editor/EditorApp.tsx` — pass `session.projectFolder` into context +**Modify:** `src/editor/useEditor.ts` — add `useProjectFolder()` hook using `useSyncExternalStore` + +### 1D. Update AssetRefField for relative paths + +**Modify:** `src/editor/fields/AssetRefField.tsx` + +In `handleFileChange`: if project folder is open, call `projectFolder.importFile(file)` and store the returned relative path as the `uri`. Fall back to blob URL when no project folder. + +### 1E. "Open Project Folder" button + +**Modify:** `src/editor/Toolbar.tsx` +- Add a `FolderRoot` icon button that calls `projectFolder.open()` +- Show folder name in tooltip when open + +### 1F. Tests + +New `src/editor/ProjectFolder.test.ts` and `src/editor/AssetUriResolver.test.ts`: +- Relative path detection and resolution +- Reference counting and URL revocation +- Filename deduplication in `importFile` + +--- + +## Phase 2: Asset Browser Panel + +### 2A. Panel placement + +Insert between EntityTree and Inspector in the right sidebar: + +``` +Toolbar +EntityTree (max-h-[40%]) +AssetBrowser ← new, collapsible +Inspector (flex-1) +``` + +### 2B. `AssetBrowser` component + +**New file:** `src/editor/AssetBrowser.tsx` + +- Shows "Open Project Folder…" prompt when no folder is open +- Collapsible header: "Assets — {folderName}" with refresh button +- Recursive tree of `FolderNode` / `FileNode` from `projectFolder.listTree()` +- Directory nodes expand/collapse +- File nodes show icon by type (lucide: `Image`, `Box`, `Film`, `Music`, `FileIcon`) +- File nodes are `draggable` — set `application/x-fnayr-asset` data (JSON with `path` and `type`) + +### 2C. Layout integration + +**Modify:** `src/editor/EditorApp.tsx` — insert `` between EntityTree and Inspector + +### 2D. Drop target on AssetRefField + +**Modify:** `src/editor/fields/AssetRefField.tsx` +- `onDragOver` / `onDrop` on the URI input area +- Accept `application/x-fnayr-asset` data, extract `path`, set as `uri` +- Visual feedback: accent border highlight during drag-over + +--- + +## Phase 3: Asset Type Detection + +### 3A. Extension-to-type utility + +**New file:** `src/editor/assetTypeDetection.ts` + +- `inferAssetType(filename) → AssetType | null` — maps `.png`→`texture`, `.glb`→`glb`, `.mp4`→`videoClip`, `.mp3`→`audioClip`, etc. +- `isTypeCompatible(expected, inferred) → boolean` + +### 3B. Integration points + +**Modify:** `src/editor/fields/AssetRefField.tsx` +- On drop (internal or external) and on Browse file pick: infer type, warn if mismatch +- Show warning as `text-amber-400 text-[10px]` below URI field + +**Modify:** `src/editor/AssetBrowser.tsx` — use `inferAssetType` for file icons and drag data + +### 3C. Tests + +New `src/editor/assetTypeDetection.test.ts`: extension mapping, case insensitivity, unknown extensions + +--- + +## Phase 4: Drag-and-Drop Import from OS + +### 4A. Viewport drop target + +**Modify:** `src/editor/Viewport.tsx` +- `onDragOver` / `onDrop` on the viewport container +- On drop with `dataTransfer.files`: infer type, call `projectFolder.importFile(file)`, create entity with appropriate component (`ModelVisual` for glb, `MeshVisual` with plane+texture for images) +- Show overlay during drag: `bg-accent/10 border-2 border-accent border-dashed` + +### 4B. Inspector field drop from OS + +**Modify:** `src/editor/fields/AssetRefField.tsx` +- Extend drop handler to also check `dataTransfer.files` (external OS drop) +- If project folder open: auto-import then set relative path +- If not: fall back to blob URL + +### 4C. Import subfolder strategy + +In `ProjectFolder.importFile()`: +- Default subfolders by type: textures → `textures/`, glb → `models/`, video → `videos/`, audio → `audio/` +- Create directories if missing +- Deduplicate filenames with numeric suffix (`brick_1.png`, etc.) + +--- + +## Phase 5: Asset Thumbnails + +### 5A. `ThumbnailCache` service + +**New file:** `src/editor/ThumbnailCache.ts` + +- `getThumbnail(relativePath) → string | null` — returns cached data URL or triggers async generation +- Image thumbnails: load into `Image`, draw onto 48x48 canvas, `.toDataURL("image/png")` +- 3D model thumbnails: offscreen `WebGLRenderer` (96x96), load GLB, auto-frame with bounding box, render one frame, `.toDataURL()` +- `subscribe / getSnapshot` for React integration + +### 5B. Integrate into editor session & context + +**Modify:** `src/editor/setup.ts` — create `ThumbnailCache` +**Modify:** `src/editor/EditorContext.tsx` — add `thumbnailCache` +**Modify:** `src/editor/useEditor.ts` — add `useThumbnailCache()` hook + +### 5C. Show thumbnails + +**Modify:** `src/editor/AssetBrowser.tsx` — show 16x16 thumbnail next to file name +**Modify:** `src/editor/fields/AssetRefField.tsx` — show 32x32 thumbnail preview next to URI input + +--- + +## Files Summary + +| Phase | New Files | Modified Files | +|-------|-----------|---------------| +| 1 | `ProjectFolder.ts`, `AssetUriResolver.ts`, `ProjectFolder.test.ts`, `AssetUriResolver.test.ts` | `setup.ts`, `EditorContext.tsx`, `EditorApp.tsx`, `useEditor.ts`, `Toolbar.tsx`, `AssetRefField.tsx`, `manager.ts` | +| 2 | `AssetBrowser.tsx` | `EditorApp.tsx` | +| 3 | `assetTypeDetection.ts`, `assetTypeDetection.test.ts` | `AssetRefField.tsx`, `AssetBrowser.tsx` | +| 4 | — | `Viewport.tsx`, `AssetRefField.tsx`, `ProjectFolder.ts` | +| 5 | `ThumbnailCache.ts` | `setup.ts`, `EditorContext.tsx`, `useEditor.ts`, `AssetBrowser.tsx`, `AssetRefField.tsx` | + +All new files go in `src/editor/` (editor-level concerns, not engine-level). + +## Verification + +After each phase: +1. `pnpm typecheck` — no type errors +2. `pnpm test:run` — all tests pass including new ones +3. `pnpm dev` — manual verification in browser: + - Phase 1: Open project folder → Browse file → URI shows relative path → Save scene → JSON contains relative paths → Load scene → assets render correctly + - Phase 2: Asset browser shows tree → drag file onto AssetRefField → URI updates + - Phase 3: Drop wrong type → warning appears + - Phase 4: Drag .glb from OS onto viewport → entity created → file copied to project folder + - Phase 5: Thumbnails appear in asset browser and inspector fields diff --git a/asset-lib.md b/asset-lib.md new file mode 100644 index 0000000..d000eb6 --- /dev/null +++ b/asset-lib.md @@ -0,0 +1,32 @@ +# Asset Library — Roadmap + +This document outlines the future roadmap for a richer asset management experience in the editor. + +## Current State + +- Asset URL fields are plain text inputs with a "Browse..." button for local file selection. +- Selected files are loaded via Blob URLs for immediate preview. +- The existing asset pipeline (texture, glb, video, audio) handles loading from any URL. + +## Future Improvements + +### Project Asset Folder +- Designate a project folder for assets on disk. +- Display a tree view of the asset folder inside the editor. +- Drag-and-drop from the asset tree onto entity fields. + +### Asset Thumbnails +- Generate and cache thumbnails for images and 3D models. +- Show thumbnails in the asset browser and inspector fields. + +### Persistent Paths +- Store relative paths instead of Blob URLs for saved scenes. +- Resolve paths at load time against the project root. + +### Drag-and-Drop Import +- Drop files from the OS file explorer directly onto the viewport or inspector. +- Auto-copy dropped files into the project asset folder. + +### Asset Type Detection +- Infer asset type from file extension when browsing/dropping. +- Warn on type mismatch (e.g., selecting a `.png` for a `glb` field). diff --git a/asset-upgrade-plan.md b/asset-upgrade-plan.md new file mode 100644 index 0000000..52b33d9 --- /dev/null +++ b/asset-upgrade-plan.md @@ -0,0 +1,211 @@ +# Plan: Lightweight Backend with 3 Abstract Services + +## Context + +The editor's save/load and asset management is entirely browser-based (File System Access API, IndexedDB, blob URLs). This limits it to Chromium, single-user, local-only. We're adding a Node.js backend with 3 abstract service interfaces, each with a zero-setup local implementation that can be swapped for production services later. + +| Service | Local (zero-setup) | Prod (swap later) | +|---|---|---| +| Database | SQLite (`better-sqlite3`) | PostgreSQL | +| Object Storage | Local filesystem (`data/blobs/`) | S3 / R2 | +| Job Queue | Inline async (`await fn()`) | BullMQ + Redis | + +## New Dependencies + +``` +pnpm add hono @hono/node-server better-sqlite3 +pnpm add -D @types/better-sqlite3 tsx concurrently +``` + +## File Structure + +### New files to create + +``` +shared/ + types.ts — TreeNode, SceneRow, BlobMeta (shared client+server types) + +server/ + tsconfig.json — Node TS config (ES2022, separate from client) + index.ts — Hono app entry, wire services, start server on :3001 + context.ts — Hono ServiceContext type (typed c.get("db") etc.) + services/ + types.ts — IDatabase, IBlobStorage, IJobQueue interfaces + database/ + sqlite.ts — SqliteDatabase: better-sqlite3, file at data/fnayr.db + blob-storage/ + local-fs.ts — LocalBlobStorage: fs read/write under data/blobs/ + job-queue/ + inline.ts — InlineJobQueue: just await handler(payload) directly + routes/ + project.ts — GET /api/health + scenes.ts — CRUD: GET/POST/PUT/DELETE /api/scenes + assets.ts — GET /api/assets/tree, GET/POST/DELETE /api/assets/file/* +``` + +### Files to modify + +| File | Change | +|---|---| +| `src/editor/ProjectFolder.ts` | Rewrite internals: replace File System Access API with `fetch()` calls to server. Same class name, same public API. Remove IndexedDB/FileSystemHandle code. | +| `src/editor/AssetUriResolver.ts` | Simplify: remove ref-counted blob URL cache. `resolve()` just calls `projectFolder.createObjectURL()` which now returns a stable server URL. `release()`/`dispose()` become no-ops. | +| `src/editor/AssetUriResolver.test.ts` | Update: remove ref-counting/revocation tests, test new simple pass-through behavior | +| `src/editor/Toolbar.tsx` | Replace File System Access save/load with server API. Add `sceneIdRef` for tracking current scene. Save → `PUT /api/scenes/:id`. Save As → `POST /api/scenes`. Load → fetch scene list, pick, load. | +| `src/editor/fields/AssetRefField.tsx` | Remove blob URL fallback paths (lines 64-67, 113-116). Always go through `projectFolder.importFile()`. | +| `vite.config.ts` | Add `/api` proxy to `localhost:3001`, add `data/` to watch ignore list | +| `package.json` | Add deps, add scripts: `dev:server`, `dev:client`, update `dev` to use `concurrently` | +| `.gitignore` | Add `data/` | + +### Files that do NOT change + +- `src/editor/setup.ts` — `projectFolder.restore()` stays, now does a health check +- `src/editor/EditorContext.tsx` — type references unchanged +- `src/editor/useEditor.ts` — hooks unchanged +- `src/editor/ThumbnailCache.ts` — calls `projectFolder.getFile()` which still works (now via fetch) +- `src/editor/AssetBrowser.tsx` — calls `listTree()/importFile()/deleteFile()` which route through server +- `src/engine/**/*` — entire engine untouched + +--- + +## Service Interfaces + +### IDatabase + +```typescript +interface SceneRow { + id: string; + name: string; + data: string; // scene JSON string + created_at: string; + updated_at: string; +} + +interface IDatabase { + listScenes(): Promise; + getScene(id: string): Promise; + createScene(name: string, data: string): Promise; + updateScene(id: string, name: string, data: string): Promise; + deleteScene(id: string): Promise; + close(): void; +} +``` + +SQLite impl: single `scenes` table, UUIDs via `crypto.randomUUID()`, db file at `data/fnayr.db`. + +### IBlobStorage + +```typescript +interface IBlobStorage { + read(path: string): Promise<{ data: Buffer; mime: string } | null>; + write(path: string, data: Buffer, mime: string): Promise; + remove(path: string): Promise; + list(): Promise; + exists(path: string): Promise; +} +``` + +Local impl: `fs` operations under `data/blobs/`. Path traversal protection (reject `..`, resolve against root). Same folder-by-type organization as current `ProjectFolder` (`inferSubfolder` logic moves to `shared/`). + +### IJobQueue + +```typescript +interface IJobQueue { + enqueue(jobType: string, payload: T): Promise; + process(jobType: string, handler: (payload: any) => Promise): void; + close(): Promise; +} +``` + +Inline impl: `enqueue()` immediately `await`s the registered handler. No Redis, no queue. + +--- + +## API Routes + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/api/health` | Health check, returns `{ status: "ok" }` | +| `GET` | `/api/scenes` | List all scenes (summary) | +| `GET` | `/api/scenes/:id` | Get scene with parsed data | +| `POST` | `/api/scenes` | Create scene `{ name, data }` → 201 | +| `PUT` | `/api/scenes/:id` | Update scene `{ name, data }` | +| `DELETE` | `/api/scenes/:id` | Delete scene → 204 | +| `GET` | `/api/assets/tree` | List asset tree (same shape as `ProjectFolder.listTree()`) | +| `GET` | `/api/assets/file/*` | Serve binary file with correct Content-Type | +| `POST` | `/api/assets/upload` | Multipart upload, returns `{ path }` | +| `DELETE` | `/api/assets/file/*` | Delete asset → 204 | + +--- + +## Key Client Changes + +### ProjectFolder.ts — Server-backed rewrite + +Same public API. Internals change to `fetch()` calls: +- `open()` → `fetch("/api/health")`, set `_connected = true` +- `restore()` → same as `open()` (auto-connect if server running) +- `getFile(path)` → `fetch("/api/assets/file/{path}")` → return as `File` +- `importFile(file)` → `FormData` POST to `/api/assets/upload` → return path +- `deleteFile(path)` → `DELETE /api/assets/file/{path}` +- `createObjectURL(path)` → return `/api/assets/file/{path}` (stable URL, no blob) +- `listTree()` → `GET /api/assets/tree` +- `name` → `"Server Project"` when connected +- `assetRootName` → `"assets"` when connected + +### AssetUriResolver.ts — Simplified + +No more caching or ref-counting. `resolve()` calls `projectFolder.createObjectURL()` which returns a stable server URL like `/api/assets/file/textures/wood.png`. `release()` and `dispose()` become no-ops. + +### Toolbar.tsx — Server scene CRUD + +- `handleSave()`: if `sceneIdRef.current`, PUT to update. Else, `handleSaveAs()`. +- `handleSaveAs()`: `window.prompt()` for name, POST to create, store returned ID. +- `handleLoad()`: fetch scene list, show simple picker (could be `window.prompt` with list for now), GET scene data, call existing `loadScene()`. +- Remove `fileHandleRef`, `showSaveFilePicker`, `showOpenFilePicker`, hidden file input. + +--- + +## Implementation Steps (test-validated) + +### Step 1: Shared types + service interfaces +- Create `shared/types.ts` (extract `TreeNode` from `ProjectFolder.ts`) +- Create `server/services/types.ts` (3 interfaces) +- Typecheck only — no runtime code yet + +### Step 2: SqliteDatabase + tests +- Implement `server/services/database/sqlite.ts` +- Test file: `server/services/database/sqlite.test.ts` +- Tests: create scene, get, list, update, delete, get nonexistent → null +- Runs against temp db file, cleaned up after each test + +### Step 3: LocalBlobStorage + tests +- Implement `server/services/blob-storage/local-fs.ts` +- Test file: `server/services/blob-storage/local-fs.test.ts` +- Tests: write/read/delete/list/exists, path traversal rejection, inferSubfolder +- Runs against temp directory, cleaned up after each test + +### Step 4: InlineJobQueue + tests +- Implement `server/services/job-queue/inline.ts` +- Test file: `server/services/job-queue/inline.test.ts` +- Tests: enqueue calls handler immediately, returns job ID, throws if no handler + +### Step 5: API routes + integration tests +- Implement routes: `project.ts`, `scenes.ts`, `assets.ts` +- Wire up in `server/index.ts` +- Test file: `server/routes/routes.test.ts` +- Use Hono's `app.request()` (no real HTTP server needed) +- Tests: scene CRUD endpoints, asset upload/download/tree/delete + +### Step 6: Client adaptation + updated tests +- Rewrite `ProjectFolder.ts` (fetch-based) +- Simplify `AssetUriResolver.ts` +- Update `AssetUriResolver.test.ts` +- Update `Toolbar.tsx` (server scene CRUD) +- Clean up `AssetRefField.tsx` (remove blob fallback) +- Update `vite.config.ts`, `package.json`, `.gitignore` + +### Step 7: End-to-end verification +- `pnpm dev` starts both server and client +- Manual smoke test: upload asset, drag to entity, save scene, reload, load scene +- `pnpm test:run` — all tests pass +- `pnpm typecheck` — both client and server typecheck diff --git a/asset-workflow.md b/asset-workflow.md new file mode 100644 index 0000000..60aa14e --- /dev/null +++ b/asset-workflow.md @@ -0,0 +1,129 @@ +# Asset & Scene Workflow + +End-to-end data flow from UI through Vite proxy to Hono server and service implementations. + +## Architecture + +``` +Browser (React + Three.js) + → fetch /api/* + → Vite dev proxy (localhost:5173 → localhost:3001) + → Hono server + → middleware injects services into context + → route handler calls IDatabase / IBlobStorage / IJobQueue + → SqliteDatabase (data/fnayr.db) + → LocalBlobStorage (data/blobs/) + → InlineJobQueue (await handler directly) +``` + +## Connection (startup) + +``` +setup.ts → projectFolder.restore() + → projectFolder.open() + → fetch GET /api/health + → project.ts: returns { status: "ok" } + ← _connected = true, _notify() +``` + +## Asset Upload + +Example: drag an image onto an AssetRefField or use Browse button. + +``` +AssetRefField.handleDrop() / handleFileChange() + → projectFolder.importFile(file) + → fetch POST /api/assets/upload (FormData with file) + → Hono middleware injects services into context + → assets.ts route handler: + c.get("storage") ← IBlobStorage from context + inferSubfolder(file.name, mime) ← shared/types.ts picks "textures/" + deduplicateName(existing, name) ← avoids collisions + storage.write("textures/brick.png", buffer, "image/png") + → LocalBlobStorage._safePath() ← path traversal check + → fs.writeFile("data/blobs/textures/brick.png") + returns { path: "textures/brick.png" } + ← projectFolder stores path, calls _notify() + → onChange({ ...value, uri: "textures/brick.png" }) +``` + +## Asset Resolve (Three.js loading) + +When the engine needs to load a texture/model referenced by a component: + +``` +AssetManager.load("textures/brick.png") + → uriResolver("textures/brick.png") ← wired in setup.ts + → AssetUriResolver.resolve("textures/brick.png") + → projectFolder.createObjectURL("textures/brick.png") + → returns "/api/assets/file/textures/brick.png" ← stable URL, no blob + ← Three.js TextureLoader fetches that URL + → Vite proxy → localhost:3001 + → assets.ts GET /api/assets/file/* + → storage.read("textures/brick.png") + → LocalBlobStorage reads data/blobs/textures/brick.png + ← Response with image bytes + Content-Type header +``` + +## Asset Tree (Asset Browser) + +``` +AssetBrowser → projectFolder.listTree() + → fetch GET /api/assets/tree + → storage.list() + → LocalBlobStorage._scanDir() recursively + → returns TreeNode[] (directories first, sorted alphabetically) + ← JSON response +``` + +## Asset Delete + +``` +AssetBrowser delete action → projectFolder.deleteFile(path) + → fetch DELETE /api/assets/file/{path} + → storage.remove(path) + → LocalBlobStorage: fs.rm(safePath) + ← 204 No Content +``` + +## Scene Save + +``` +Toolbar.handleSave() + → if sceneIdRef.current exists: + fetch PUT /api/scenes/:id { name, data: worldToJson() } + → scenes.ts route: + c.get("db").updateScene(id, name, JSON.stringify(data)) + → SqliteDatabase: UPDATE scenes SET ... WHERE id = ? + → better-sqlite3 writes to data/fnayr.db + → else handleSaveAs(): + window.prompt("Scene name:") + fetch POST /api/scenes { name, data } + → db.createScene(name, data) + → INSERT INTO scenes ... with crypto.randomUUID() + ← returns { id, name, data, created_at, updated_at } + sceneIdRef.current = id ← subsequent saves use PUT +``` + +## Scene Load + +``` +Toolbar.handleLoad() + → fetch GET /api/scenes + → db.listScenes() ← returns summaries (no data field) + → window.prompt() picker + → fetch GET /api/scenes/:id + → db.getScene(id) ← returns full row, data parsed as JSON + → loadScene(scene.data) + → destroys all entities + → parseWorld() + recreates with new IDs + → restores hierarchy + → sceneIdRef.current = id +``` + +## Key Design Decisions + +- **Stable URLs instead of blob URLs**: `createObjectURL()` returns `/api/assets/file/{path}` — no caching, no ref-counting, no revocation needed. `AssetUriResolver.release()` and `dispose()` are no-ops. +- **Server handles subfolder inference**: `inferSubfolder()` moved to `shared/types.ts`, used by both the upload route and (previously) the client. The server picks the right subfolder (textures/, models/, etc.) based on MIME type and file extension. +- **Service interfaces are swappable**: `IDatabase`, `IBlobStorage`, `IJobQueue` can be replaced with PostgreSQL, S3, BullMQ without changing routes or client code. +- **No IndexedDB or File System Access API**: The old browser-only code (directory picker, IDB handle persistence) is fully replaced by `fetch()` calls. Works in any browser. diff --git a/design-system.md b/design-system.md new file mode 100644 index 0000000..abd601e --- /dev/null +++ b/design-system.md @@ -0,0 +1,279 @@ +# Forge — FNAYR Editor Design System + +## Philosophy + +**Industrial precision.** The editor UI exists to stay out of the way while giving total control. Every pixel serves the workflow. Cool dark slate grounds the eye; warm amber draws it where action is needed. Typography is functional — geometric sans for labels, monospace for values. No decoration without purpose. + +## Color Palette + +All color tokens are defined via `@theme` in `src/index.css` and available as Tailwind utility classes (e.g., `bg-panel`, `text-primary`, `border-subtle`). + +### Backgrounds (darkest → lightest) + +| Token | CSS Variable | Hex | Usage | +|-------------------|--------------------------|-------------|------------------------------------------| +| `editor-bg` | `--color-editor-bg` | `#1a1b1e` | Canvas area, app-level background | +| `input` | `--color-input` | `#16171a` | Sunken input fields, text areas | +| `panel-alt` | `--color-panel-alt` | `#1e1f23` | Title bar, secondary panel backgrounds | +| `panel` | `--color-panel` | `#212226` | Primary panel/sidebar background | +| `surface` | `--color-surface` | `#2a2b30` | Interactive surfaces, component headers | +| `surface-hover` | `--color-surface-hover` | `#313238` | Hovered interactive surfaces | + +### Borders + +| Token | CSS Variable | Hex | Usage | +|------------|--------------------|-------------|-----------------------------------------------| +| `subtle` | `--color-subtle` | `#2e2f35` | Panel dividers, input borders (resting) | +| `border` | `--color-border` | `#363740` | Input borders (hover), stronger separators | + +### Text + +| Token | CSS Variable | Hex | Usage | +|-------------|----------------------|-------------|----------------------------------------------| +| `primary` | `--color-primary` | `#cdced3` | Body text, active labels, input values | +| `secondary` | `--color-secondary` | `#a0a1a8` | Toolbar icons, list items, less emphasis | +| `muted` | `--color-muted` | `#6b6d76` | Labels, placeholders, disabled text | + +### Accent + +| Token | CSS Variable | Hex | Usage | +|----------------|------------------------|---------------|---------------------------------------------| +| `accent` | `--color-accent` | `#e8a84c` | Primary accent — focus rings, active states, slider thumbs, checkboxes | +| `accent-hover` | `--color-accent-hover` | `#f0b865` | Hovered accent elements | +| `accent-dim` | `--color-accent-dim` | `#e8a84c20` | Active button backgrounds (12% opacity) | +| `selected` | `--color-selected` | `#e8a84c16` | Selected row backgrounds (9% opacity) | +| `focus` | `--color-focus` | `#e8a84c` | Focus-visible outlines (same as accent) | + +### Semantic + +| Token | CSS Variable | Hex | Usage | +|---------------|-----------------------|---------------|--------------------------------------------| +| `danger` | `--color-danger` | `#e85454` | Delete hover, destructive action text | +| `danger-dim` | `--color-danger-dim` | `#e8545418` | Danger button hover backgrounds | + +### Axis Colors (used in vector fields) + +| Axis | Tailwind Class | Purpose | +|------|----------------------|---------------| +| X | `text-red-400/80` | X-axis label | +| Y | `text-green-400/80` | Y-axis label | +| Z | `text-blue-400/80` | Z-axis label | +| W | `text-purple-400/80` | W-axis label | + +## Typography + +### Font Families + +| Token | CSS Variable | Stack | Usage | +|----------|-----------------|-----------------------------------------------------------|--------------------------------| +| `editor` | `--font-editor` | `"DM Sans", ui-sans-serif, system-ui, -apple-system, sans-serif` | All UI text | +| `mono` | `--font-mono` | `"JetBrains Mono", ui-monospace, monospace` | Numeric values, entity IDs, hex codes | + +Both loaded from Google Fonts with subsets for weight 300–600 (DM Sans) and 400–500 (JetBrains Mono). + +### Font Sizes + +All font sizes are CSS variables in `@theme`, so changing one value updates every component that uses it. + +| Token | CSS Variable | Value | Tailwind Class | Usage | +|------------|-----------------|---------|----------------|-------------------------------------------------| +| `header` | `--text-header` | `9px` | `text-header` | Panel section headers (uppercase + tracking) | +| `label` | `--text-label` | `10px` | `text-label` | Field labels, browse buttons, axis labels | +| `body` | `--text-body` | `11px` | `text-body` | Body text, input values, list items | + +### Text Treatments + +- **Panel headers**: `text-header font-medium uppercase tracking-widest text-muted` +- **Field labels**: `text-label text-muted font-medium` +- **Component names**: `text-body font-medium text-primary` +- **Numeric values**: `font-mono text-body text-primary` +- **Entity ID badge**: `text-header font-mono text-muted/60 normal-case tracking-normal` + +## Spacing + +The system uses Tailwind's default 4px grid. Common patterns: + +| Context | Padding | Gap | +|------------------------|------------------|---------| +| Panel header | `px-3 py-2` | — | +| Component header | `px-3 py-1.5` | — | +| Component body | `px-3 py-2` | — | +| Entity row | `px-2 py-[3px]` | `gap-1.5` | +| Toolbar buttons | `p-1.5` | `gap-0.5` | +| Field stacks | — | `gap-2` | +| Vector field row | — | `gap-1` | +| Draggable number | `px-1.5 py-0.5` | `gap-0.5` | + +Tree indentation: `14px` per depth level, starting at `8px` base padding. + +## Components + +### Panel Header + +Consistent treatment across Scene, Inspector, and Assets panels: + +``` +px-3 py-2 text-muted font-medium uppercase tracking-widest text-[9px] border-b border-subtle +``` + +Often includes a flex row with action buttons (Plus, Refresh) on the right side. + +### Toolbar Button + +```tsx +p-1.5 rounded text-secondary hover:text-primary hover:bg-surface +disabled:opacity-30 disabled:cursor-default +// Active state: +bg-accent-dim text-accent ring-1 ring-accent/40 +``` + +Always uses `cursor-pointer`. Icons are 14px with `strokeWidth={1.75}`. + +### Input Field (text, number) + +``` +bg-input border border-subtle rounded px-1.5 py-1 text-primary +outline-none focus:border-focus hover:border-border text-[11px] +``` + +- Resting: `border-subtle` +- Hover: `border-border` +- Focus: `border-focus` (amber) +- Edit mode (active number): `border-accent/40` + +### Select / Dropdown + +``` +bg-input border border-subtle rounded px-1.5 py-1 text-primary +outline-none focus:border-focus hover:border-border cursor-pointer text-[11px] +``` + +Custom SVG chevron arrow replaces native appearance. `padding-right: 22px` to accommodate. + +### Draggable Number + +Dual-mode field (display → edit on click, drag to scrub): + +- **Display**: `bg-input border-subtle rounded cursor-ew-resize select-none font-mono` +- **Edit**: `bg-input border-accent/40 rounded font-mono` (text input) +- Label: colored per axis (X=red, Y=green, Z=blue, W=purple), `font-mono font-medium w-3 text-center` + +### Popup Menu + +``` +bg-panel border border-border rounded-md shadow-xl shadow-black/40 py-1 min-w-[130px] +``` + +Menu items: +``` +flex items-center gap-2 px-3 py-1.5 text-[11px] text-secondary +hover:text-primary hover:bg-surface cursor-pointer +``` + +### Entity Row + +``` +group flex items-center gap-1.5 hover:bg-surface/60 +// Selected: +bg-selected text-primary font-medium +// Default: +text-secondary +``` + +Delete button: `opacity-0 group-hover:opacity-100 text-muted hover:text-danger` + +### Component Section (Inspector) + +- **Header**: `bg-surface/50 hover:bg-surface cursor-pointer select-none` with chevron + name + remove button +- **Body**: `px-3 py-2` containing SchemaField +- **Separator**: `border-b border-subtle` between sections +- Remove button: `hover:text-danger hover:bg-danger-dim rounded p-0.5` + +### Drag-and-Drop Overlay + +**Viewport**: +``` +absolute inset-2 border-2 border-accent/50 border-dashed rounded-lg +// Center label: +bg-panel/90 backdrop-blur-sm px-4 py-2 rounded-md border border-accent/30 +text-accent text-xs font-medium +``` + +**Asset browser / fields**: `ring-1 ring-inset ring-accent/50` + +## Form Controls (CSS-level) + +All native form controls are fully restyled in `index.css` to eliminate browser chrome: + +- **Range sliders**: 3px track (`subtle`), 12px circular thumb (`accent`) with panel-colored border, scale on hover +- **Checkboxes**: 14px square, `input` bg + `border` border, checked fills `accent` with CSS checkmark pseudo-element +- **Color inputs**: No wrapper padding, swatch has 3px border-radius +- **Number inputs**: Spinner buttons removed (both webkit and moz) +- **Selects**: Native appearance removed, custom SVG chevron arrow + +## Motion + +Global transition on all interactive elements: +```css +transition: background-color 0.12s ease, border-color 0.12s ease, + color 0.12s ease, opacity 0.12s ease, box-shadow 0.12s ease; +``` + +Range slider thumb: `transition: transform 0.1s ease` for hover scale. + +Checkbox: `transition: all 0.15s ease` for color fill. + +No spring animations or keyframe sequences — motion is subtle and utilitarian. + +## Scrollbars + +```css +width: 5px / height: 5px +track: transparent +thumb: subtle (resting) → muted (hover) +border-radius: 4px +``` + +## Icons + +All icons from `lucide-react`. Standard sizes: + +| Context | Size | strokeWidth | +|-----------------|------|-------------| +| Toolbar | 14 | 1.75 | +| Panel actions | 11–13| 2 | +| List items | 12 | 2 | +| Asset browser | 14 | 1.75 | +| Delete (inline) | 10–11| 1.75–2 | + +## Layout Structure + +Layout dimensions are CSS variables — tweak the sidebar width or title bar height in one place. + +| Token | CSS Variable | Value | Tailwind Class | Usage | +|-------------|--------------------|----------|----------------|---------------------| +| `sidebar` | `--width-sidebar` | `290px` | `w-sidebar` | Right sidebar width | +| `titlebar` | `--height-titlebar`| `2rem` | `h-titlebar` | Top title bar height| + +``` +┌─────────────────────────────────────────────────────────┐ +│ Title Bar (h-titlebar, bg-panel-alt) │ +│ [Logo] FNAYR Editor [Save][SaveAs][Open]... │ +├──────────────────────────────────┬──┬────────────────────┤ +│ │ │ Scene Panel │ +│ │ │ (max 40% height) │ +│ Viewport │ ├────────────────────┤ +│ (flex-1, bg-editor-bg) │1px│ Inspector │ +│ │ │ (flex-1, scroll) │ +│ │ ├────────────────────┤ +│ │ │ Assets │ +│ │ │ (min 120px) │ +└──────────────────────────────────┴──┴────────────────────┘ + ↑ + Sidebar: w-sidebar, bg-panel +``` + +- Title bar: `h-titlebar` (default 2rem / 32px) +- Sidebar: `w-sidebar` (default 290px), flex column with scroll regions +- Viewport: fills remaining space +- 1px `bg-subtle` divider between viewport and sidebar diff --git a/index.html b/index.html index 2cf3cb4..faea407 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,12 @@ ECS Editor +
diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e175595 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2850 @@ +{ + "name": "fnayr", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fnayr", + "version": "0.0.0", + "dependencies": { + "lucide-react": "^0.563.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "three": "^0.182.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.18", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@types/three": "^0.182.0", + "@vitejs/plugin-react": "^4.2.0", + "fast-check": "^4.5.3", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vite": "^5.4.0", + "vitest": "^2.0.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", + "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.22.0" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-check": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.5.3.tgz", + "integrity": "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^7.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.563.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz", + "integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", + "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json index ce8684b..3e5dc9a 100644 --- a/package.json +++ b/package.json @@ -4,27 +4,42 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "concurrently --names server,client \"pnpm dev:server\" \"pnpm dev:client\"", + "dev:server": "tsx watch server/index.ts", + "dev:client": "vite", "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", + "typecheck:server": "tsc --noEmit -p server/tsconfig.json", "test": "vitest", "test:run": "vitest run" }, "dependencies": { + "@hono/node-server": "^1.19.9", + "better-sqlite3": "^12.6.2", + "hono": "^4.11.9", "lucide-react": "^0.563.0", "react": "^18.2.0", "react-dom": "^18.2.0", "three": "^0.182.0" }, + "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3" + ] + }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^25.2.2", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@types/three": "^0.182.0", "@vitejs/plugin-react": "^4.2.0", + "concurrently": "^9.2.1", "fast-check": "^4.5.3", "tailwindcss": "^4.1.18", + "tsx": "^4.21.0", "typescript": "^5.4.0", "vite": "^5.4.0", "vitest": "^2.0.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94180be..13e6102 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@hono/node-server': + specifier: ^1.19.9 + version: 1.19.9(hono@4.11.9) + better-sqlite3: + specifier: ^12.6.2 + version: 12.6.2 + hono: + specifier: ^4.11.9 + version: 4.11.9 lucide-react: specifier: ^0.563.0 version: 0.563.0(react@18.3.1) @@ -23,7 +32,13 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.1.18 - version: 4.1.18(vite@5.4.21(lightningcss@1.30.2)) + version: 4.1.18(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.30.2)) + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + '@types/node': + specifier: ^25.2.2 + version: 25.2.2 '@types/react': specifier: ^18.2.0 version: 18.3.28 @@ -35,22 +50,28 @@ importers: version: 0.182.0 '@vitejs/plugin-react': specifier: ^4.2.0 - version: 4.7.0(vite@5.4.21(lightningcss@1.30.2)) + version: 4.7.0(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.30.2)) + concurrently: + specifier: ^9.2.1 + version: 9.2.1 fast-check: specifier: ^4.5.3 version: 4.5.3 tailwindcss: specifier: ^4.1.18 version: 4.1.18 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.4.0 version: 5.9.3 vite: specifier: ^5.4.0 - version: 5.4.21(lightningcss@1.30.2) + version: 5.4.21(@types/node@25.2.2)(lightningcss@1.30.2) vitest: specifier: ^2.0.5 - version: 2.1.9(lightningcss@1.30.2) + version: 2.1.9(@types/node@25.2.2)(lightningcss@1.30.2) packages: @@ -146,138 +167,300 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -527,9 +710,15 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@25.2.2': + resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -588,19 +777,43 @@ packages: '@webgpu/types@0.1.69': resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true + better-sqlite3@12.6.2: + resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -612,10 +825,33 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concurrently@9.2.1: + resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} + engines: {node: '>=18'} + hasBin: true + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -631,10 +867,18 @@ packages: supports-color: optional: true + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -642,6 +886,12 @@ packages: electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.19.0: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} @@ -654,6 +904,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -661,6 +916,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -672,6 +931,12 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -681,9 +946,40 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hono@4.11.9: + resolution: {integrity: sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==} + engines: {node: '>=16.9.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -792,6 +1088,16 @@ packages: meshoptimizer@0.22.0: resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -800,9 +1106,19 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.87.0: + resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} + engines: {node: '>=10'} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -817,9 +1133,21 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -833,11 +1161,28 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -845,9 +1190,24 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -858,6 +1218,29 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + tailwindcss@4.1.18: resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} @@ -865,6 +1248,13 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + three@0.182.0: resolution: {integrity: sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==} @@ -886,17 +1276,38 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -963,9 +1374,28 @@ packages: engines: {node: '>=8'} hasBin: true + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + snapshots: '@babel/code-frame@7.29.0': @@ -1085,72 +1515,154 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.27.3': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.27.3': + optional: true + '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.27.3': + optional: true + '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.27.3': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.27.3': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.27.3': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.27.3': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.27.3': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.27.3': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.27.3': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.27.3': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.27.3': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.27.3': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.27.3': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.27.3': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.27.3': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.27.3': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.27.3': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.27.3': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.27.3': + optional: true + + '@hono/node-server@1.19.9(hono@4.11.9)': + dependencies: + hono: 4.11.9 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1308,12 +1820,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@5.4.21(lightningcss@1.30.2))': + '@tailwindcss/vite@4.1.18(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.30.2))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 5.4.21(lightningcss@1.30.2) + vite: 5.4.21(@types/node@25.2.2)(lightningcss@1.30.2) '@tweenjs/tween.js@23.1.3': {} @@ -1338,8 +1850,16 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 25.2.2 + '@types/estree@1.0.8': {} + '@types/node@25.2.2': + dependencies: + undici-types: 7.16.0 + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.28)': @@ -1365,7 +1885,7 @@ snapshots: '@types/webxr@0.5.24': {} - '@vitejs/plugin-react@4.7.0(vite@5.4.21(lightningcss@1.30.2))': + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.30.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -1373,7 +1893,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21(lightningcss@1.30.2) + vite: 5.4.21(@types/node@25.2.2)(lightningcss@1.30.2) transitivePeerDependencies: - supports-color @@ -1384,13 +1904,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.9(vite@5.4.21(lightningcss@1.30.2))': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.30.2))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(lightningcss@1.30.2) + vite: 5.4.21(@types/node@25.2.2)(lightningcss@1.30.2) '@vitest/pretty-format@2.1.9': dependencies: @@ -1419,10 +1939,33 @@ snapshots: '@webgpu/types@0.1.69': {} + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + assertion-error@2.0.1: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.9.19: {} + better-sqlite3@12.6.2: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.19 @@ -1431,6 +1974,11 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + cac@6.7.14: {} caniuse-lite@1.0.30001768: {} @@ -1443,8 +1991,36 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + check-error@2.1.3: {} + chownr@1.1.4: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concurrently@9.2.1: + dependencies: + chalk: 4.1.2 + rxjs: 7.8.2 + shell-quote: 1.8.3 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + convert-source-map@2.0.0: {} csstype@3.2.3: {} @@ -1453,12 +2029,24 @@ snapshots: dependencies: ms: 2.1.3 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} + detect-libc@2.1.2: {} electron-to-chromium@1.5.286: {} + emoji-regex@8.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.19.0: dependencies: graceful-fs: 4.2.11 @@ -1492,12 +2080,43 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + escalade@3.2.0: {} estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + expand-template@2.0.3: {} + expect-type@1.3.0: {} fast-check@4.5.3: @@ -1506,13 +2125,37 @@ snapshots: fflate@0.8.2: {} + file-uri-to-path@1.0.0: {} + + fs-constants@1.0.0: {} + fsevents@2.3.3: optional: true gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-from-package@0.0.0: {} + graceful-fs@4.2.11: {} + has-flag@4.0.0: {} + + hono@4.11.9: {} + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + is-fullwidth-code-point@3.0.0: {} + jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -1590,12 +2233,28 @@ snapshots: meshoptimizer@0.22.0: {} + mimic-response@3.1.0: {} + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + ms@2.1.3: {} nanoid@3.3.11: {} + napi-build-utils@2.0.0: {} + + node-abi@3.87.0: + dependencies: + semver: 7.7.4 + node-releases@2.0.27: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + pathe@1.1.2: {} pathval@2.0.1: {} @@ -1608,8 +2267,35 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.87.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + pure-rand@7.0.1: {} + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -1622,6 +2308,16 @@ snapshots: dependencies: loose-envify: 1.4.0 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + require-directory@2.1.1: {} + + resolve-pkg-maps@1.0.0: {} + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -1653,24 +2349,81 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 semver@6.3.1: {} + semver@7.7.4: {} + + shell-quote@1.8.3: {} + siginfo@2.0.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + source-map-js@1.2.1: {} stackback@0.0.2: {} std-env@3.10.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@2.0.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + tailwindcss@4.1.18: {} tapable@2.3.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + three@0.182.0: {} tinybench@2.9.0: {} @@ -1683,21 +2436,40 @@ snapshots: tinyspy@3.0.2: {} + tree-kill@1.2.2: {} + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.3 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + typescript@5.9.3: {} + undici-types@7.16.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 escalade: 3.2.0 picocolors: 1.1.1 - vite-node@2.1.9(lightningcss@1.30.2): + util-deprecate@1.0.2: {} + + vite-node@2.1.9(@types/node@25.2.2)(lightningcss@1.30.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21(lightningcss@1.30.2) + vite: 5.4.21(@types/node@25.2.2)(lightningcss@1.30.2) transitivePeerDependencies: - '@types/node' - less @@ -1709,19 +2481,20 @@ snapshots: - supports-color - terser - vite@5.4.21(lightningcss@1.30.2): + vite@5.4.21(@types/node@25.2.2)(lightningcss@1.30.2): dependencies: esbuild: 0.21.5 postcss: 8.5.6 rollup: 4.57.1 optionalDependencies: + '@types/node': 25.2.2 fsevents: 2.3.3 lightningcss: 1.30.2 - vitest@2.1.9(lightningcss@1.30.2): + vitest@2.1.9(@types/node@25.2.2)(lightningcss@1.30.2): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(lightningcss@1.30.2)) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.30.2)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -1737,9 +2510,11 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21(lightningcss@1.30.2) - vite-node: 2.1.9(lightningcss@1.30.2) + vite: 5.4.21(@types/node@25.2.2)(lightningcss@1.30.2) + vite-node: 2.1.9(@types/node@25.2.2)(lightningcss@1.30.2) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.2.2 transitivePeerDependencies: - less - lightningcss @@ -1756,4 +2531,26 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + y18n@5.0.8: {} + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 diff --git a/public/models/Soldier (4).glb b/public/models/Soldier (4).glb new file mode 100644 index 0000000..1788f12 Binary files /dev/null and b/public/models/Soldier (4).glb differ diff --git a/public/models/akm_reload_animation.glb b/public/models/akm_reload_animation.glb new file mode 100644 index 0000000..cb86d77 Binary files /dev/null and b/public/models/akm_reload_animation.glb differ diff --git a/public/textures/Screenshot 2026-02-09 at 23.59.01.png b/public/textures/Screenshot 2026-02-09 at 23.59.01.png new file mode 100644 index 0000000..855e041 Binary files /dev/null and b/public/textures/Screenshot 2026-02-09 at 23.59.01.png differ diff --git a/server/app.ts b/server/app.ts new file mode 100644 index 0000000..288dc53 --- /dev/null +++ b/server/app.ts @@ -0,0 +1,23 @@ +import { Hono } from "hono"; +import type { ServiceContext } from "./context.js"; +import type { IDatabase, IBlobStorage, IJobQueue } from "./services/types.js"; +import { project } from "./routes/project.js"; +import { scenes } from "./routes/scenes.js"; +import { assets } from "./routes/assets.js"; + +export function createApp(db: IDatabase, storage: IBlobStorage, queue: IJobQueue) { + const app = new Hono(); + + app.use("*", async (c, next) => { + c.set("db", db); + c.set("storage", storage); + c.set("queue", queue); + await next(); + }); + + app.route("/api", project); + app.route("/api/scenes", scenes); + app.route("/api/assets", assets); + + return app; +} diff --git a/server/context.ts b/server/context.ts new file mode 100644 index 0000000..b677637 --- /dev/null +++ b/server/context.ts @@ -0,0 +1,9 @@ +import type { IDatabase, IBlobStorage, IJobQueue } from "./services/types.js"; + +export type ServiceContext = { + Variables: { + db: IDatabase; + storage: IBlobStorage; + queue: IJobQueue; + }; +}; diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..b55b501 --- /dev/null +++ b/server/index.ts @@ -0,0 +1,40 @@ +import { Hono } from "hono"; +import { serve } from "@hono/node-server"; +import { resolve } from "node:path"; +import { mkdirSync } from "node:fs"; +import type { ServiceContext } from "./context.js"; +import { SqliteDatabase } from "./services/database/sqlite.js"; +import { LocalBlobStorage } from "./services/blob-storage/local-fs.js"; +import { InlineJobQueue } from "./services/job-queue/inline.js"; +import { project } from "./routes/project.js"; +import { scenes } from "./routes/scenes.js"; +import { assets } from "./routes/assets.js"; + +const DATA_DIR = resolve("data"); +mkdirSync(DATA_DIR, { recursive: true }); + +const db = new SqliteDatabase(resolve(DATA_DIR, "fnayr.db")); +const storage = new LocalBlobStorage(resolve(DATA_DIR, "blobs")); +const queue = new InlineJobQueue(); + +const app = new Hono(); + +// Inject services into context +app.use("*", async (c, next) => { + c.set("db", db); + c.set("storage", storage); + c.set("queue", queue); + await next(); +}); + +app.route("/api", project); +app.route("/api/scenes", scenes); +app.route("/api/assets", assets); + +const port = Number(process.env.PORT ?? 3001); + +serve({ fetch: app.fetch, port }, () => { + console.log(`Server running on http://localhost:${port}`); +}); + +export { app }; diff --git a/server/routes/assets.ts b/server/routes/assets.ts new file mode 100644 index 0000000..5f440e0 --- /dev/null +++ b/server/routes/assets.ts @@ -0,0 +1,62 @@ +import { Hono } from "hono"; +import type { ServiceContext } from "../context.js"; +import { inferSubfolder, deduplicateName } from "../../shared/types.js"; + +const assets = new Hono(); + +assets.get("/tree", async (c) => { + const storage = c.get("storage"); + const tree = await storage.list(); + return c.json(tree); +}); + +assets.get("/file/*", async (c) => { + const storage = c.get("storage"); + const path = c.req.path.replace(/^\/api\/assets\/file\//, ""); + if (!path) return c.json({ error: "Path required" }, 400); + + const result = await storage.read(path); + if (!result) return c.json({ error: "Not found" }, 404); + + return new Response(new Uint8Array(result.data), { + status: 200, + headers: { "Content-Type": result.mime }, + }); +}); + +assets.post("/upload", async (c) => { + const storage = c.get("storage"); + const formData = await c.req.formData(); + const file = formData.get("file") as File | null; + if (!file) return c.json({ error: "No file provided" }, 400); + + const mime = file.type || "application/octet-stream"; + const subfolder = inferSubfolder(file.name, mime); + const data = Buffer.from(await file.arrayBuffer()); + + // Deduplicate name within subfolder + const tree = await storage.list(); + const folderNode = tree.find((n) => n.kind === "directory" && n.name === subfolder); + const existing = new Set(); + if (folderNode && folderNode.kind === "directory") { + for (const child of folderNode.children) { + existing.add(child.name); + } + } + const finalName = deduplicateName(existing, file.name); + const path = `${subfolder}/${finalName}`; + + await storage.write(path, data, mime); + return c.json({ path }, 201); +}); + +assets.delete("/file/*", async (c) => { + const storage = c.get("storage"); + const path = c.req.path.replace(/^\/api\/assets\/file\//, ""); + if (!path) return c.json({ error: "Path required" }, 400); + + await storage.remove(path); + return c.body(null, 204); +}); + +export { assets }; diff --git a/server/routes/project.ts b/server/routes/project.ts new file mode 100644 index 0000000..f397345 --- /dev/null +++ b/server/routes/project.ts @@ -0,0 +1,10 @@ +import { Hono } from "hono"; +import type { ServiceContext } from "../context.js"; + +const project = new Hono(); + +project.get("/health", (c) => { + return c.json({ status: "ok" }); +}); + +export { project }; diff --git a/server/routes/routes.test.ts b/server/routes/routes.test.ts new file mode 100644 index 0000000..584f216 --- /dev/null +++ b/server/routes/routes.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { rm, mkdir } from "node:fs/promises"; +import { createApp } from "../app.js"; +import { SqliteDatabase } from "../services/database/sqlite.js"; +import { LocalBlobStorage } from "../services/blob-storage/local-fs.js"; +import { InlineJobQueue } from "../services/job-queue/inline.js"; + +let tmpDir: string; +let db: SqliteDatabase; +let storage: LocalBlobStorage; +let queue: InlineJobQueue; +let app: ReturnType; + +beforeEach(async () => { + tmpDir = join(tmpdir(), `routes-test-${randomUUID()}`); + await mkdir(tmpDir, { recursive: true }); + db = new SqliteDatabase(join(tmpDir, "test.db")); + storage = new LocalBlobStorage(join(tmpDir, "blobs")); + queue = new InlineJobQueue(); + app = createApp(db, storage, queue); +}); + +afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); +}); + +describe("Health", () => { + it("GET /api/health returns ok", async () => { + const res = await app.request("/api/health"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "ok" }); + }); +}); + +describe("Scenes CRUD", () => { + it("POST /api/scenes creates a scene", async () => { + const res = await app.request("/api/scenes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test Scene", data: { entities: [] } }), + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.name).toBe("Test Scene"); + expect(body.id).toBeDefined(); + expect(body.data).toEqual({ entities: [] }); + }); + + it("GET /api/scenes lists scenes", async () => { + await app.request("/api/scenes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "S1", data: {} }), + }); + await app.request("/api/scenes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "S2", data: {} }), + }); + + const res = await app.request("/api/scenes"); + expect(res.status).toBe(200); + const list = await res.json(); + expect(list).toHaveLength(2); + // List should not include data + expect(list[0].data).toBeUndefined(); + }); + + it("GET /api/scenes/:id returns a scene", async () => { + const createRes = await app.request("/api/scenes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "S1", data: { hello: true } }), + }); + const { id } = await createRes.json(); + + const res = await app.request(`/api/scenes/${id}`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe("S1"); + expect(body.data).toEqual({ hello: true }); + }); + + it("GET /api/scenes/:id returns 404 for unknown", async () => { + const res = await app.request("/api/scenes/nonexistent"); + expect(res.status).toBe(404); + }); + + it("PUT /api/scenes/:id updates a scene", async () => { + const createRes = await app.request("/api/scenes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Old", data: {} }), + }); + const { id } = await createRes.json(); + + const res = await app.request(`/api/scenes/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "New", data: { updated: true } }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe("New"); + expect(body.data).toEqual({ updated: true }); + }); + + it("DELETE /api/scenes/:id deletes a scene", async () => { + const createRes = await app.request("/api/scenes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "ToDelete", data: {} }), + }); + const { id } = await createRes.json(); + + const delRes = await app.request(`/api/scenes/${id}`, { method: "DELETE" }); + expect(delRes.status).toBe(204); + + const getRes = await app.request(`/api/scenes/${id}`); + expect(getRes.status).toBe(404); + }); +}); + +describe("Assets", () => { + it("POST /api/assets/upload uploads a file", async () => { + const formData = new FormData(); + formData.append("file", new File(["pixel data"], "test.png", { type: "image/png" })); + + const res = await app.request("/api/assets/upload", { + method: "POST", + body: formData, + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.path).toBe("textures/test.png"); + }); + + it("GET /api/assets/file/* serves an uploaded file", async () => { + const content = "hello file"; + const formData = new FormData(); + formData.append("file", new File([content], "doc.txt", { type: "text/plain" })); + + await app.request("/api/assets/upload", { method: "POST", body: formData }); + + const res = await app.request("/api/assets/file/assets/doc.txt"); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toBe(content); + }); + + it("GET /api/assets/file/* returns 404 for missing", async () => { + const res = await app.request("/api/assets/file/nope.png"); + expect(res.status).toBe(404); + }); + + it("GET /api/assets/tree returns asset tree", async () => { + const formData = new FormData(); + formData.append("file", new File(["px"], "a.png", { type: "image/png" })); + await app.request("/api/assets/upload", { method: "POST", body: formData }); + + const res = await app.request("/api/assets/tree"); + expect(res.status).toBe(200); + const tree = await res.json(); + expect(tree.length).toBeGreaterThan(0); + }); + + it("DELETE /api/assets/file/* deletes an asset", async () => { + const formData = new FormData(); + formData.append("file", new File(["px"], "del.png", { type: "image/png" })); + const uploadRes = await app.request("/api/assets/upload", { method: "POST", body: formData }); + const { path } = await uploadRes.json(); + + const delRes = await app.request(`/api/assets/file/${path}`, { method: "DELETE" }); + expect(delRes.status).toBe(204); + + const getRes = await app.request(`/api/assets/file/${path}`); + expect(getRes.status).toBe(404); + }); +}); diff --git a/server/routes/scenes.ts b/server/routes/scenes.ts new file mode 100644 index 0000000..2b31545 --- /dev/null +++ b/server/routes/scenes.ts @@ -0,0 +1,43 @@ +import { Hono } from "hono"; +import type { ServiceContext } from "../context.js"; + +const scenes = new Hono(); + +scenes.get("/", async (c) => { + const db = c.get("db"); + const rows = await db.listScenes(); + return c.json(rows.map(({ id, name, created_at, updated_at }) => ({ id, name, created_at, updated_at }))); +}); + +scenes.get("/:id", async (c) => { + const db = c.get("db"); + const row = await db.getScene(c.req.param("id")); + if (!row) return c.json({ error: "Not found" }, 404); + return c.json({ ...row, data: JSON.parse(row.data) }); +}); + +scenes.post("/", async (c) => { + const db = c.get("db"); + const { name, data } = await c.req.json<{ name: string; data: unknown }>(); + const row = await db.createScene(name, JSON.stringify(data)); + return c.json({ ...row, data: JSON.parse(row.data) }, 201); +}); + +scenes.put("/:id", async (c) => { + const db = c.get("db"); + const { name, data } = await c.req.json<{ name: string; data: unknown }>(); + try { + const row = await db.updateScene(c.req.param("id"), name, JSON.stringify(data)); + return c.json({ ...row, data: JSON.parse(row.data) }); + } catch { + return c.json({ error: "Not found" }, 404); + } +}); + +scenes.delete("/:id", async (c) => { + const db = c.get("db"); + await db.deleteScene(c.req.param("id")); + return c.body(null, 204); +}); + +export { scenes }; diff --git a/server/services/blob-storage/local-fs.test.ts b/server/services/blob-storage/local-fs.test.ts new file mode 100644 index 0000000..85d2cff --- /dev/null +++ b/server/services/blob-storage/local-fs.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { rm } from "node:fs/promises"; +import { LocalBlobStorage } from "./local-fs.js"; + +function makeTmpDir(): string { + return join(tmpdir(), `blob-test-${randomUUID()}`); +} + +describe("LocalBlobStorage", () => { + const dirs: string[] = []; + + function createStorage(): LocalBlobStorage { + const dir = makeTmpDir(); + dirs.push(dir); + return new LocalBlobStorage(dir); + } + + afterEach(async () => { + for (const dir of dirs) { + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } + dirs.length = 0; + }); + + it("write then read: data and mime match", async () => { + const storage = createStorage(); + const data = Buffer.from("hello world"); + const meta = await storage.write("test.png", data, "image/png"); + + expect(meta).toEqual({ path: "test.png", mime: "image/png", size: data.length }); + + const result = await storage.read("test.png"); + expect(result).not.toBeNull(); + expect(result!.data).toEqual(data); + expect(result!.mime).toBe("image/png"); + }); + + it("read nonexistent file returns null", async () => { + const storage = createStorage(); + const result = await storage.read("does-not-exist.png"); + expect(result).toBeNull(); + }); + + it("write creates parent directories", async () => { + const storage = createStorage(); + const data = Buffer.from("nested content"); + await storage.write("sub/deep/file.txt", data, "text/plain"); + + const result = await storage.read("sub/deep/file.txt"); + expect(result).not.toBeNull(); + expect(result!.data).toEqual(data); + }); + + it("delete file: exists returns false after", async () => { + const storage = createStorage(); + const data = Buffer.from("to be deleted"); + await storage.write("deleteme.png", data, "image/png"); + + expect(await storage.exists("deleteme.png")).toBe(true); + + await storage.remove("deleteme.png"); + + expect(await storage.exists("deleteme.png")).toBe(false); + }); + + it("remove nonexistent file does not throw", async () => { + const storage = createStorage(); + await expect(storage.remove("nope.txt")).resolves.toBeUndefined(); + }); + + it("list returns correct tree structure", async () => { + const storage = createStorage(); + await storage.write("textures/brick.png", Buffer.from("a"), "image/png"); + await storage.write("textures/stone.png", Buffer.from("b"), "image/png"); + await storage.write("models/cube.glb", Buffer.from("c"), "model/gltf-binary"); + await storage.write("readme.txt", Buffer.from("d"), "text/plain"); + + const tree = await storage.list(); + + // Directories come first, sorted alphabetically, then files + expect(tree).toEqual([ + { + kind: "directory", + name: "models", + path: "models", + children: [{ kind: "file", name: "cube.glb", path: "models/cube.glb" }], + }, + { + kind: "directory", + name: "textures", + path: "textures", + children: [ + { kind: "file", name: "brick.png", path: "textures/brick.png" }, + { kind: "file", name: "stone.png", path: "textures/stone.png" }, + ], + }, + { kind: "file", name: "readme.txt", path: "readme.txt" }, + ]); + }); + + it("path traversal rejection: ../etc/passwd throws", async () => { + const storage = createStorage(); + await expect(storage.read("../etc/passwd")).rejects.toThrow("Path traversal not allowed"); + await expect(storage.write("../etc/passwd", Buffer.from("x"), "text/plain")).rejects.toThrow( + "Path traversal not allowed", + ); + await expect(storage.remove("../etc/passwd")).rejects.toThrow("Path traversal not allowed"); + await expect(storage.exists("../etc/passwd")).rejects.toThrow("Path traversal not allowed"); + }); + + it("exists: true for existing, false for non-existing", async () => { + const storage = createStorage(); + expect(await storage.exists("nope.png")).toBe(false); + + await storage.write("yes.png", Buffer.from("data"), "image/png"); + expect(await storage.exists("yes.png")).toBe(true); + }); +}); diff --git a/server/services/blob-storage/local-fs.ts b/server/services/blob-storage/local-fs.ts new file mode 100644 index 0000000..4fc6fb9 --- /dev/null +++ b/server/services/blob-storage/local-fs.ts @@ -0,0 +1,119 @@ +import { readFile, writeFile, rm, stat, readdir, mkdir } from "node:fs/promises"; +import { mkdirSync } from "node:fs"; +import { resolve, relative, dirname, extname, join } from "node:path"; +import type { IBlobStorage } from "../types.js"; +import type { BlobMeta, TreeNode } from "../../../shared/types.js"; + +const MIME_BY_EXT: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".glb": "model/gltf-binary", + ".gltf": "model/gltf+json", + ".fbx": "application/octet-stream", + ".obj": "text/plain", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".ogg": "audio/ogg", +}; + +function guessMime(filePath: string): string { + const ext = extname(filePath).toLowerCase(); + return MIME_BY_EXT[ext] ?? "application/octet-stream"; +} + +export class LocalBlobStorage implements IBlobStorage { + private readonly rootDir: string; + + constructor(rootDir: string) { + this.rootDir = resolve(rootDir); + mkdirSync(this.rootDir, { recursive: true }); + } + + private _safePath(path: string): string { + if (path.includes("..")) { + throw new Error(`Path traversal not allowed: ${path}`); + } + const full = resolve(this.rootDir, path); + if (!full.startsWith(this.rootDir)) { + throw new Error(`Path traversal not allowed: ${path}`); + } + return full; + } + + async read(path: string): Promise<{ data: Buffer; mime: string } | null> { + const full = this._safePath(path); + try { + const data = await readFile(full); + const mime = guessMime(full); + return { data, mime }; + } catch (err: any) { + if (err.code === "ENOENT") return null; + throw err; + } + } + + async write(path: string, data: Buffer, mime: string): Promise { + const full = this._safePath(path); + await mkdir(dirname(full), { recursive: true }); + await writeFile(full, data); + return { path, mime, size: data.length }; + } + + async remove(path: string): Promise { + const full = this._safePath(path); + try { + await rm(full); + } catch (err: any) { + if (err.code === "ENOENT") return; + throw err; + } + } + + async list(): Promise { + return this._scanDir(this.rootDir); + } + + async exists(path: string): Promise { + const full = this._safePath(path); + try { + await stat(full); + return true; + } catch { + return false; + } + } + + private async _scanDir(dir: string): Promise { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return []; + } + + const dirs: TreeNode[] = []; + const files: TreeNode[] = []; + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + const relPath = relative(this.rootDir, fullPath); + + if (entry.isDirectory()) { + const children = await this._scanDir(fullPath); + dirs.push({ kind: "directory", name: entry.name, path: relPath, children }); + } else { + files.push({ kind: "file", name: entry.name, path: relPath }); + } + } + + dirs.sort((a, b) => a.name.localeCompare(b.name)); + files.sort((a, b) => a.name.localeCompare(b.name)); + + return [...dirs, ...files]; + } +} diff --git a/server/services/database/sqlite.test.ts b/server/services/database/sqlite.test.ts new file mode 100644 index 0000000..a79ec78 --- /dev/null +++ b/server/services/database/sqlite.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { SqliteDatabase } from "./sqlite.js"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; +import crypto from "node:crypto"; + +describe("SqliteDatabase", () => { + let db: SqliteDatabase; + let dbPath: string; + + function createDb() { + dbPath = path.join(os.tmpdir(), `fnayr-test-${crypto.randomUUID()}.db`); + db = new SqliteDatabase(dbPath); + return db; + } + + afterEach(() => { + try { + db?.close(); + } catch { + // already closed + } + try { + if (dbPath) fs.unlinkSync(dbPath); + } catch { + // file may not exist + } + }); + + it("creates a scene with id, name, data, and timestamps", async () => { + createDb(); + const scene = await db.createScene("My Scene", '{"entities":[]}'); + + expect(scene.id).toBeDefined(); + expect(typeof scene.id).toBe("string"); + expect(scene.id.length).toBeGreaterThan(0); + expect(scene.name).toBe("My Scene"); + expect(scene.data).toBe('{"entities":[]}'); + expect(scene.created_at).toBeDefined(); + expect(scene.updated_at).toBeDefined(); + expect(scene.created_at).toBe(scene.updated_at); + }); + + it("gets a scene by id", async () => { + createDb(); + const created = await db.createScene("Test", "{}"); + const fetched = await db.getScene(created.id); + + expect(fetched).not.toBeNull(); + expect(fetched!.id).toBe(created.id); + expect(fetched!.name).toBe("Test"); + expect(fetched!.data).toBe("{}"); + expect(fetched!.created_at).toBe(created.created_at); + expect(fetched!.updated_at).toBe(created.updated_at); + }); + + it("returns null for a nonexistent scene", async () => { + createDb(); + const result = await db.getScene("nonexistent-id"); + expect(result).toBeNull(); + }); + + it("lists all created scenes", async () => { + createDb(); + await db.createScene("Scene A", '{"a":1}'); + await db.createScene("Scene B", '{"b":2}'); + await db.createScene("Scene C", '{"c":3}'); + + const scenes = await db.listScenes(); + expect(scenes).toHaveLength(3); + + const names = scenes.map((s) => s.name); + expect(names).toContain("Scene A"); + expect(names).toContain("Scene B"); + expect(names).toContain("Scene C"); + }); + + it("updates a scene name and data", async () => { + createDb(); + const original = await db.createScene("Old Name", '{"old":true}'); + + // Small delay to ensure updated_at differs + await new Promise((r) => setTimeout(r, 10)); + + const updated = await db.updateScene(original.id, "New Name", '{"new":true}'); + + expect(updated.id).toBe(original.id); + expect(updated.name).toBe("New Name"); + expect(updated.data).toBe('{"new":true}'); + expect(updated.created_at).toBe(original.created_at); + expect(updated.updated_at).not.toBe(original.updated_at); + }); + + it("deletes a scene so it is no longer retrievable", async () => { + createDb(); + const scene = await db.createScene("To Delete", "{}"); + + await db.deleteScene(scene.id); + + const result = await db.getScene(scene.id); + expect(result).toBeNull(); + }); +}); diff --git a/server/services/database/sqlite.ts b/server/services/database/sqlite.ts new file mode 100644 index 0000000..6ea353a --- /dev/null +++ b/server/services/database/sqlite.ts @@ -0,0 +1,67 @@ +import Database from "better-sqlite3"; +import crypto from "node:crypto"; +import type { IDatabase, SceneRow } from "../types.js"; + +export class SqliteDatabase implements IDatabase { + private db: InstanceType; + + constructor(dbPath: string) { + this.db = new Database(dbPath); + this.db.pragma("journal_mode = WAL"); + this.db.exec(` + CREATE TABLE IF NOT EXISTS scenes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + data TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); + } + + async listScenes(): Promise { + return this.db + .prepare("SELECT id, name, data, created_at, updated_at FROM scenes") + .all() as SceneRow[]; + } + + async getScene(id: string): Promise { + const row = this.db + .prepare("SELECT id, name, data, created_at, updated_at FROM scenes WHERE id = ?") + .get(id) as SceneRow | undefined; + return row ?? null; + } + + async createScene(name: string, data: string): Promise { + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + this.db + .prepare( + "INSERT INTO scenes (id, name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + ) + .run(id, name, data, now, now); + return { id, name, data, created_at: now, updated_at: now }; + } + + async updateScene(id: string, name: string, data: string): Promise { + const now = new Date().toISOString(); + const result = this.db + .prepare("UPDATE scenes SET name = ?, data = ?, updated_at = ? WHERE id = ?") + .run(name, data, now, id); + if (result.changes === 0) { + throw new Error(`Scene not found: ${id}`); + } + const row = this.db + .prepare("SELECT id, name, data, created_at, updated_at FROM scenes WHERE id = ?") + .get(id) as SceneRow; + return row; + } + + async deleteScene(id: string): Promise { + this.db.prepare("DELETE FROM scenes WHERE id = ?").run(id); + } + + close(): void { + this.db.close(); + } +} diff --git a/server/services/job-queue/inline.test.ts b/server/services/job-queue/inline.test.ts new file mode 100644 index 0000000..239b6ec --- /dev/null +++ b/server/services/job-queue/inline.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { InlineJobQueue } from "./inline"; + +describe("InlineJobQueue", () => { + it("enqueue calls handler immediately with correct payload", async () => { + const queue = new InlineJobQueue(); + const handler = vi.fn().mockResolvedValue(undefined); + const payload = { url: "https://example.com", retries: 3 }; + + queue.process("download", handler); + await queue.enqueue("download", payload); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(payload); + }); + + it("enqueue returns a string job ID", async () => { + const queue = new InlineJobQueue(); + queue.process("ping", vi.fn().mockResolvedValue(undefined)); + + const id = await queue.enqueue("ping", {}); + + expect(typeof id).toBe("string"); + expect(id.length).toBeGreaterThan(0); + }); + + it("enqueue throws if no handler registered", async () => { + const queue = new InlineJobQueue(); + + await expect(queue.enqueue("unknown", {})).rejects.toThrow( + "No handler registered for job type: unknown", + ); + }); + + it("process registers handler that can be called multiple times", async () => { + const queue = new InlineJobQueue(); + const handler = vi.fn().mockResolvedValue(undefined); + + queue.process("email", handler); + + await queue.enqueue("email", { to: "a@b.com" }); + await queue.enqueue("email", { to: "c@d.com" }); + await queue.enqueue("email", { to: "e@f.com" }); + + expect(handler).toHaveBeenCalledTimes(3); + expect(handler).toHaveBeenNthCalledWith(1, { to: "a@b.com" }); + expect(handler).toHaveBeenNthCalledWith(2, { to: "c@d.com" }); + expect(handler).toHaveBeenNthCalledWith(3, { to: "e@f.com" }); + }); + + it("close clears handlers", async () => { + const queue = new InlineJobQueue(); + queue.process("task", vi.fn().mockResolvedValue(undefined)); + + await queue.close(); + + await expect(queue.enqueue("task", {})).rejects.toThrow( + "No handler registered for job type: task", + ); + }); +}); diff --git a/server/services/job-queue/inline.ts b/server/services/job-queue/inline.ts new file mode 100644 index 0000000..67404b2 --- /dev/null +++ b/server/services/job-queue/inline.ts @@ -0,0 +1,22 @@ +import type { IJobQueue } from "../types.js"; + +export class InlineJobQueue implements IJobQueue { + private handlers = new Map Promise>(); + + process(jobType: string, handler: (payload: any) => Promise): void { + this.handlers.set(jobType, handler); + } + + async enqueue(jobType: string, payload: T): Promise { + const handler = this.handlers.get(jobType); + if (!handler) { + throw new Error(`No handler registered for job type: ${jobType}`); + } + await handler(payload); + return crypto.randomUUID(); + } + + async close(): Promise { + this.handlers.clear(); + } +} diff --git a/server/services/types.ts b/server/services/types.ts new file mode 100644 index 0000000..448f6be --- /dev/null +++ b/server/services/types.ts @@ -0,0 +1,26 @@ +import type { SceneRow, TreeNode, BlobMeta } from "../../shared/types.js"; + +export type { SceneRow, TreeNode, BlobMeta }; + +export interface IDatabase { + listScenes(): Promise; + getScene(id: string): Promise; + createScene(name: string, data: string): Promise; + updateScene(id: string, name: string, data: string): Promise; + deleteScene(id: string): Promise; + close(): void; +} + +export interface IBlobStorage { + read(path: string): Promise<{ data: Buffer; mime: string } | null>; + write(path: string, data: Buffer, mime: string): Promise; + remove(path: string): Promise; + list(): Promise; + exists(path: string): Promise; +} + +export interface IJobQueue { + enqueue(jobType: string, payload: T): Promise; + process(jobType: string, handler: (payload: any) => Promise): void; + close(): Promise; +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..47ab766 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node"] + }, + "include": [".", "../shared"] +} diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 0000000..2b6dbae --- /dev/null +++ b/shared/types.ts @@ -0,0 +1,55 @@ +export type FileNode = { kind: "file"; name: string; path: string }; +export type FolderNode = { + kind: "directory"; + name: string; + path: string; + children: TreeNode[]; +}; +export type TreeNode = FileNode | FolderNode; + +export interface SceneRow { + id: string; + name: string; + data: string; + created_at: string; + updated_at: string; +} + +export interface BlobMeta { + path: string; + mime: string; + size: number; +} + +const SUBFOLDER_BY_MIME: [RegExp, string][] = [ + [/^image\//, "textures"], + [/^video\//, "videos"], + [/^audio\//, "audio"], +]; + +const SUBFOLDER_BY_EXT: Record = { + ".glb": "models", + ".gltf": "models", + ".fbx": "models", + ".obj": "models", +}; + +export function inferSubfolder(fileName: string, mimeType: string): string { + for (const [re, folder] of SUBFOLDER_BY_MIME) { + if (re.test(mimeType)) return folder; + } + const ext = fileName.includes(".") + ? "." + fileName.split(".").pop()!.toLowerCase() + : ""; + return SUBFOLDER_BY_EXT[ext] ?? "assets"; +} + +export function deduplicateName(existing: Set, name: string): string { + if (!existing.has(name)) return name; + const dot = name.lastIndexOf("."); + const base = dot > 0 ? name.slice(0, dot) : name; + const ext = dot > 0 ? name.slice(dot) : ""; + let i = 1; + while (existing.has(`${base}_${i}${ext}`)) i++; + return `${base}_${i}${ext}`; +} diff --git a/src/demo/three-demo.ts b/src/demo/three-demo.ts index 21e80eb..106c83a 100644 --- a/src/demo/three-demo.ts +++ b/src/demo/three-demo.ts @@ -140,10 +140,9 @@ const COLORS: [number, number, number, number][] = [ const colorCycleSystem = (w: World) => { if (!input.isKeyJustPressed("Space")) return; colorIndex = (colorIndex + 1) % COLORS.length; - for (const { entity, components } of w.query(["Spin", "VisualRenderer"])) { - if (components.VisualRenderer.kind !== "mesh") continue; - const mr = w.getMut(entity, "VisualRenderer")! as any; - mr.color = COLORS[colorIndex]; + for (const { entity } of w.query(["Spin", "MeshVisual"])) { + const mv = w.getMut(entity, "MeshVisual")! as any; + mv.color = COLORS[colorIndex]; } }; @@ -166,10 +165,10 @@ world.setComponent(box, "Transform3D", { rotation: [0, 0, 0, 1], scale: [1, 1, 1], }); -world.setComponent(box, "VisualRenderer", { - kind: "mesh", - geometry: "box", +world.setComponent(box, "MeshVisual", { + geometry: { kind: "box", width: 1, height: 1, depth: 1 }, color: [0.9, 0.15, 0.15, 1], + texture: { kind: "asset", type: "texture", uri: "" }, }); world.setComponent(box, "Spin", { speed: 1 }); @@ -180,10 +179,10 @@ world.setComponent(ground, "Transform3D", { rotation: [-Math.SQRT1_2, 0, 0, Math.SQRT1_2], // rotate -90deg around X scale: [10, 10, 1], }); -world.setComponent(ground, "VisualRenderer", { - kind: "mesh", - geometry: "plane", +world.setComponent(ground, "MeshVisual", { + geometry: { kind: "plane", width: 1, height: 1 }, color: [0.2, 0.7, 0.2, 1], + texture: { kind: "asset", type: "texture", uri: "" }, }); // Run the render sync manually to build the initial scene graph diff --git a/src/editor/AssetBrowser.tsx b/src/editor/AssetBrowser.tsx new file mode 100644 index 0000000..3bee489 --- /dev/null +++ b/src/editor/AssetBrowser.tsx @@ -0,0 +1,299 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { + ChevronRight, + ChevronDown, + Image, + Box, + Film, + Music, + FileIcon, + RefreshCw, + FolderOpen, + Plus, + Trash2, +} from "lucide-react"; +import { useEditor, useProjectFolder, useThumbnailCache } from "./useEditor"; +import type { TreeNode, FolderNode } from "./ProjectFolder"; +import type { ThumbnailCache } from "./ThumbnailCache"; +import type { EcsWorld } from "../engine/ecs/world"; +import type { renderingRegistry } from "../engine/rendering/components"; +import { inferAssetType } from "./assetTypeDetection"; + +type Registry = typeof renderingRegistry; + +/** Recursively search a value for any `uri` property matching the path. */ +function deepHasUri(value: unknown, uri: string): boolean { + if (value == null || typeof value !== "object") return false; + const obj = value as Record; + if (obj.uri === uri) return true; + for (const v of Object.values(obj)) { + if (deepHasUri(v, uri)) return true; + } + return false; +} + +/** Return entity names that reference the given asset path. */ +function findUsages(world: EcsWorld, assetPath: string): string[] { + const users: string[] = []; + const compTypes = ["MeshVisual", "ModelVisual"] as const; + world.forEachEntity((entity) => { + for (const comp of compTypes) { + if (!world.hasComponent(entity, comp)) continue; + const data = world.getComponent(entity, comp); + if (deepHasUri(data, assetPath)) { + const meta = world.getComponent(entity, "Meta") as { name: string } | undefined; + users.push(meta?.name || `Entity ${entity}`); + break; + } + } + }); + return users; +} + +function iconForFile(name: string) { + const type = inferAssetType(name); + const props = { size: 14, strokeWidth: 1.75 }; + switch (type) { + case "texture": + return ; + case "glb": + return ; + case "videoClip": + return ; + case "audioClip": + return ; + default: + return ; + } +} + +type FileEntryProps = { + node: TreeNode & { kind: "file" }; + thumbnailCache: ThumbnailCache; + onDelete: (path: string) => void; +}; + +function FileEntry({ node, thumbnailCache, onDelete }: FileEntryProps) { + const thumb = thumbnailCache.getThumbnail(node.path); + return ( +
{ + const data = JSON.stringify({ + path: node.path, + type: inferAssetType(node.name), + }); + e.dataTransfer.setData("application/x-fnayr-asset", data); + e.dataTransfer.effectAllowed = "copy"; + }} + className="group flex items-center gap-2 px-3 py-1 text-secondary hover:text-primary hover:bg-surface/60 cursor-grab text-body" + > + {thumb ? ( + + ) : ( + {iconForFile(node.name)} + )} + {node.name} + +
+ ); +} + +type DirEntryProps = { + node: FolderNode; + thumbnailCache: ThumbnailCache; + onDelete: (path: string) => void; +}; + +function DirEntry({ node, thumbnailCache, onDelete }: DirEntryProps) { + const [open, setOpen] = useState(false); + + return ( +
+ + {open && ( +
+ {node.children.map((child) => + child.kind === "directory" ? ( + + ) : ( + + ), + )} +
+ )} +
+ ); +} + +export function AssetBrowser() { + const { world } = useEditor(); + const projectFolder = useProjectFolder(); + const thumbnailCache = useThumbnailCache(); + const [tree, setTree] = useState([]); + const [collapsed, setCollapsed] = useState(false); + const [dragOver, setDragOver] = useState(false); + const fileInputRef = useRef(null); + + const refresh = useCallback(() => { + projectFolder.listTree().then(setTree); + }, [projectFolder]); + + useEffect(() => { + if (projectFolder.isOpen) refresh(); + else setTree([]); + }, [projectFolder, projectFolder.isOpen, refresh]); + + async function importFiles(files: FileList | File[]) { + for (const file of files) { + try { + await projectFolder.importFile(file); + } catch (err) { + console.warn("Failed to import file:", file.name, err); + } + } + refresh(); + } + + async function handleDelete(path: string) { + const users = findUsages(world, path); + if (users.length > 0) { + const names = users.join(", "); + const ok = window.confirm( + `"${path}" is used by: ${names}.\n\nDelete anyway?`, + ); + if (!ok) return; + } + try { + await projectFolder.deleteFile(path); + thumbnailCache.invalidate(path); + refresh(); + } catch (err) { + console.warn("Failed to delete asset:", path, err); + } + } + + const handleDragOver = (e: React.DragEvent) => { + if (e.dataTransfer.types.includes("Files")) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setDragOver(true); + } + }; + + const handleDragLeave = (e: React.DragEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setDragOver(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + if (e.dataTransfer.files.length > 0) { + importFiles(e.dataTransfer.files); + } + }; + + const handleBrowse = () => fileInputRef.current?.click(); + + const handleFileInput = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + importFiles(files); + } + e.target.value = ""; + }; + + if (!projectFolder.isOpen) { + return ( +
+
+ Assets +
+ +
+ ); + } + + return ( +
+
+ +
+ + +
+
+ {!collapsed && ( +
+ {tree.length === 0 ? ( +
+ Drop files here or click + to add assets +
+ ) : ( + tree.map((node) => + node.kind === "directory" ? ( + + ) : ( + + ), + ) + )} +
+ )} + +
+ ); +} diff --git a/src/editor/AssetUriResolver.test.ts b/src/editor/AssetUriResolver.test.ts new file mode 100644 index 0000000..88680ac --- /dev/null +++ b/src/editor/AssetUriResolver.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from "vitest"; +import { AssetUriResolver } from "./AssetUriResolver"; +import type { ProjectFolder } from "./ProjectFolder"; + +function makeMockFolder(open = true): ProjectFolder { + return { + isOpen: open, + createObjectURL: vi.fn((path: string) => `/api/assets/file/${path}`), + } as unknown as ProjectFolder; +} + +describe("AssetUriResolver", () => { + describe("isRelativePath", () => { + it("returns true for relative paths", () => { + const resolver = new AssetUriResolver(makeMockFolder()); + expect(resolver.isRelativePath("textures/brick.png")).toBe(true); + expect(resolver.isRelativePath("models/car.glb")).toBe(true); + }); + + it("returns false for absolute/blob/data/api URIs", () => { + const resolver = new AssetUriResolver(makeMockFolder()); + expect(resolver.isRelativePath("http://example.com/a.png")).toBe(false); + expect(resolver.isRelativePath("https://example.com/a.png")).toBe(false); + expect(resolver.isRelativePath("blob:abc123")).toBe(false); + expect(resolver.isRelativePath("data:image/png;base64,")).toBe(false); + expect(resolver.isRelativePath("/api/assets/file/textures/a.png")).toBe(false); + }); + + it("returns false for empty string", () => { + const resolver = new AssetUriResolver(makeMockFolder()); + expect(resolver.isRelativePath("")).toBe(false); + }); + }); + + describe("resolve", () => { + it("passes through absolute URIs", () => { + const resolver = new AssetUriResolver(makeMockFolder()); + expect(resolver.resolve("http://example.com/a.png")).toBe("http://example.com/a.png"); + expect(resolver.resolve("blob:abc")).toBe("blob:abc"); + }); + + it("passes through empty string", () => { + const resolver = new AssetUriResolver(makeMockFolder()); + expect(resolver.resolve("")).toBe(""); + }); + + it("resolves relative path to server URL", () => { + const folder = makeMockFolder(); + const resolver = new AssetUriResolver(folder); + const result = resolver.resolve("textures/brick.png"); + expect(result).toBe("/api/assets/file/textures/brick.png"); + expect(folder.createObjectURL).toHaveBeenCalledWith("textures/brick.png"); + }); + + it("passes through relative paths when folder is not open", () => { + const folder = makeMockFolder(false); + const resolver = new AssetUriResolver(folder); + expect(resolver.resolve("textures/brick.png")).toBe("textures/brick.png"); + }); + }); + + describe("release", () => { + it("is a no-op (does not throw)", () => { + const resolver = new AssetUriResolver(makeMockFolder()); + expect(() => resolver.release("textures/brick.png")).not.toThrow(); + }); + }); + + describe("dispose", () => { + it("is a no-op (does not throw)", () => { + const resolver = new AssetUriResolver(makeMockFolder()); + expect(() => resolver.dispose()).not.toThrow(); + }); + }); +}); diff --git a/src/editor/AssetUriResolver.ts b/src/editor/AssetUriResolver.ts new file mode 100644 index 0000000..433a694 --- /dev/null +++ b/src/editor/AssetUriResolver.ts @@ -0,0 +1,31 @@ +import type { ProjectFolder } from "./ProjectFolder"; + +export class AssetUriResolver { + constructor(private _projectFolder: ProjectFolder) {} + + isRelativePath(uri: string): boolean { + if (!uri) return false; + return ( + !uri.startsWith("http://") && + !uri.startsWith("https://") && + !uri.startsWith("blob:") && + !uri.startsWith("data:") && + !uri.startsWith("/api/") + ); + } + + resolve(uri: string): string { + if (!uri) return uri; + if (!this.isRelativePath(uri)) return uri; + if (!this._projectFolder.isOpen) return uri; + return this._projectFolder.createObjectURL(uri); + } + + release(_uri: string): void { + // No-op: server URLs are stable, no blob URLs to revoke + } + + dispose(): void { + // No-op: no cached blob URLs to clean up + } +} diff --git a/src/editor/EditorApp.tsx b/src/editor/EditorApp.tsx index 7a0cbb9..9eae79e 100644 --- a/src/editor/EditorApp.tsx +++ b/src/editor/EditorApp.tsx @@ -6,9 +6,31 @@ import { Viewport } from "./Viewport"; import { Toolbar } from "./Toolbar"; import { EntityTree } from "./EntityTree"; import { Inspector } from "./Inspector"; +import { AssetBrowser } from "./AssetBrowser"; +import { ThemeTweakerButton, ThemeTweakerPanel } from "./ThemeTweaker"; +import { useSceneName } from "./useEditor"; +import { Sun, Moon } from "lucide-react"; + +function SceneLabel() { + const sceneName = useSceneName(); + return ( + + {sceneName ?? "Untitled"} + + ); +} + +type Theme = "dark" | "light"; + +function getInitialTheme(): Theme { + const stored = localStorage.getItem("editor-theme"); + return stored === "light" ? "light" : "dark"; +} export function EditorApp() { const [session, setSession] = useState(null); + const [themePanelOpen, setThemePanelOpen] = useState(false); + const [theme, setTheme] = useState(getInitialTheme); const canvasRef = useRef(null); useEffect(() => { @@ -21,6 +43,15 @@ export function EditorApp() { }; }, []); + useEffect(() => { + if (theme === "light") { + document.documentElement.dataset.theme = "light"; + } else { + delete document.documentElement.dataset.theme; + } + localStorage.setItem("editor-theme", theme); + }, [theme]); + if (!session) return null; const ctx: EditorContextValue = { @@ -30,19 +61,55 @@ export function EditorApp() { binding: session.binding, gizmo: session.gizmo, controls: session.controls, + projectFolder: session.projectFolder, + thumbnailCache: session.thumbnailCache, }; return ( -
- -
- -
- +
+ {/* Title bar */} +
+
+
+ F +
+ FNAYR + +
+ setThemePanelOpen(!themePanelOpen)} /> +
-
- + +
+ + {/* Main content */} +
+ {/* Theme panel — docked left of viewport */} + {themePanelOpen && ( + setThemePanelOpen(false)} activeTheme={theme} /> + )} + + + + {/* Sidebar resize handle visual */} +
+ + {/* Right sidebar */} +
+
+ +
+
+ +
+
diff --git a/src/editor/EditorContext.tsx b/src/editor/EditorContext.tsx index bf0552b..f4991d7 100644 --- a/src/editor/EditorContext.tsx +++ b/src/editor/EditorContext.tsx @@ -6,6 +6,8 @@ import type { EcsWorld } from "../engine/ecs/world"; import type { renderingRegistry } from "../engine/rendering/components"; import type { GizmoManager } from "./GizmoManager"; import type { EditorCameraControls } from "./EditorCameraControls"; +import type { ProjectFolder } from "./ProjectFolder"; +import type { ThumbnailCache } from "./ThumbnailCache"; type Registry = typeof renderingRegistry; @@ -16,6 +18,8 @@ export type EditorContextValue = { binding: ThreeBinding; gizmo: GizmoManager; controls: EditorCameraControls; + projectFolder: ProjectFolder; + thumbnailCache: ThumbnailCache; }; export const EditorContext = createContext(null); diff --git a/src/editor/EditorStore.ts b/src/editor/EditorStore.ts index 4c62ecb..8401e66 100644 --- a/src/editor/EditorStore.ts +++ b/src/editor/EditorStore.ts @@ -11,8 +11,12 @@ export class EditorStore< private selectedEntity: number | null = null; private readonly selectionListeners = new Set<() => void>(); private readonly entityListeners = new Set<() => void>(); + private readonly sceneListeners = new Set<() => void>(); private entityVersion = 0; private selectionVersion = 0; + private sceneVersion = 0; + private sceneId: string | null = null; + private sceneName: string | null = null; private readonly unsubComponentChanged: () => void; private readonly unsubEntityDestroyed: () => void; @@ -73,11 +77,49 @@ export class EditorStore< }; }; + getSceneId(): string | null { + return this.sceneId; + } + + getSceneName(): string | null { + return this.sceneName; + } + + private static LAST_SCENE_KEY = "editor-last-scene-id"; + + setScene(id: string | null, name: string | null): void { + this.sceneId = id; + this.sceneName = name; + if (id) { + localStorage.setItem(EditorStore.LAST_SCENE_KEY, id); + } else { + localStorage.removeItem(EditorStore.LAST_SCENE_KEY); + } + this.sceneVersion++; + this.notifyScene(); + } + + getLastSceneId(): string | null { + return localStorage.getItem(EditorStore.LAST_SCENE_KEY); + } + + getSceneSnapshot = (): number => { + return this.sceneVersion; + }; + + subscribeScene = (callback: () => void): (() => void) => { + this.sceneListeners.add(callback); + return () => { + this.sceneListeners.delete(callback); + }; + }; + dispose(): void { this.unsubComponentChanged(); this.unsubEntityDestroyed(); this.selectionListeners.clear(); this.entityListeners.clear(); + this.sceneListeners.clear(); } private notifySelection(): void { @@ -91,4 +133,10 @@ export class EditorStore< listener(); } } + + private notifyScene(): void { + for (const listener of this.sceneListeners) { + listener(); + } + } } diff --git a/src/editor/EntityTree.tsx b/src/editor/EntityTree.tsx index 342a7f7..828d7fe 100644 --- a/src/editor/EntityTree.tsx +++ b/src/editor/EntityTree.tsx @@ -1,81 +1,199 @@ -import { Plus, Trash2 } from "lucide-react"; +import { useState, useRef, useEffect, useCallback } from "react"; +import { Plus, Trash2, Box, Circle, Square, Layers, Package } from "lucide-react"; import { useEditor, useSelectedEntity, useEntities } from "./useEditor"; +type Preset = { + label: string; + name: string; + icon: React.ReactNode; + components: Record; +}; + +const iconProps = { size: 12, strokeWidth: 2 }; + +const PRESETS: Preset[] = [ + { + label: "Empty", + name: "Entity", + icon: , + components: {}, + }, + { + label: "Cube", + name: "Cube", + icon: , + components: { + MeshVisual: { + geometry: { kind: "box", width: 1, height: 1, depth: 1 }, + color: [0.8, 0.8, 0.8, 1], + texture: { kind: "asset", type: "texture", uri: "" }, + }, + }, + }, + { + label: "Sphere", + name: "Sphere", + icon: , + components: { + MeshVisual: { + geometry: { kind: "sphere", radius: 0.5, widthSegments: 32, heightSegments: 16 }, + color: [0.8, 0.8, 0.8, 1], + texture: { kind: "asset", type: "texture", uri: "" }, + }, + }, + }, + { + label: "Plane", + name: "Plane", + icon: , + components: { + MeshVisual: { + geometry: { kind: "plane", width: 1, height: 1 }, + color: [0.8, 0.8, 0.8, 1], + texture: { kind: "asset", type: "texture", uri: "" }, + }, + }, + }, + { + label: "Model", + name: "Model", + icon: , + components: { + ModelVisual: { + asset: { kind: "asset", type: "glb", uri: "" }, + }, + }, + }, +]; + export function EntityTree() { const { world, hierarchy, store } = useEditor(); const selectedEntity = useSelectedEntity(); + const [renamingEntity, setRenamingEntity] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const renameInputRef = useRef(null); // Subscribe to entity changes so tree re-renders when entities change useEntities(); + useEffect(() => { + if (renamingEntity !== null) { + renameInputRef.current?.focus(); + renameInputRef.current?.select(); + } + }, [renamingEntity]); + const entities: number[] = []; world.forEachEntity((e) => entities.push(e)); // Build root entities (no parent) const roots = entities.filter((e) => hierarchy.getParent(e) === undefined); - function handleCreateEntity() { + const [showAddMenu, setShowAddMenu] = useState(false); + const addMenuRef = useRef(null); + + // Close menu on outside click + useEffect(() => { + if (!showAddMenu) return; + function onPointerDown(e: PointerEvent) { + if (addMenuRef.current && !addMenuRef.current.contains(e.target as Node)) { + setShowAddMenu(false); + } + } + document.addEventListener("pointerdown", onPointerDown, true); + return () => document.removeEventListener("pointerdown", onPointerDown, true); + }, [showAddMenu]); + + const handleCreateEntity = useCallback((preset: Preset) => { const entity = world.createEntity(); + world.setComponent(entity, "Meta", { name: preset.name }); world.setComponent(entity, "Transform3D", { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1], }); + for (const [comp, data] of Object.entries(preset.components)) { + world.setComponent(entity, comp as any, data as any); + } store.selectEntity(entity); - } + setShowAddMenu(false); + }, [world, store]); function handleDeleteEntity(entity: number) { world.destroyEntity(entity); } + function startRename(entity: number, currentName: string) { + setRenamingEntity(entity); + setRenameValue(currentName); + } + + function commitRename(entity: number) { + const name = renameValue.trim(); + const existing = world.getComponent(entity, "Meta") as { name: string } | undefined; + world.setComponent(entity, "Meta", { ...existing, name }); + setRenamingEntity(null); + } + + function cancelRename() { + setRenamingEntity(null); + } + function renderEntity(entity: number, depth: number) { const children = hierarchy.getChildren(entity); const isSelected = entity === selectedEntity; - const components: string[] = []; - for (const type of Object.keys(world.registry) as Array) { - if (world.hasComponent(entity, type)) { - components.push(type); - } - } + const meta = world.getComponent(entity, "Meta") as { name: string } | undefined; + const label = meta?.name || "Entity"; + const isRenaming = renamingEntity === entity; return (
- - - {components.map((c) => ( - - {c} - - ))} - - + )} +
{children.map((child) => renderEntity(child as number, depth + 1))}
@@ -84,17 +202,42 @@ export function EntityTree() { return (
-
- Entities - + {/* Panel header */} +
+ Scene +
+ + {showAddMenu && ( +
+ {PRESETS.map((preset) => ( + + ))} +
+ )} +
- {roots.map((entity) => renderEntity(entity, 0))} + {roots.length === 0 ? ( +
+ No entities in scene +
+ ) : ( +
+ {roots.map((entity) => renderEntity(entity, 0))} +
+ )}
); } diff --git a/src/editor/Inspector.tsx b/src/editor/Inspector.tsx index cce63d9..98f72a3 100644 --- a/src/editor/Inspector.tsx +++ b/src/editor/Inspector.tsx @@ -1,5 +1,5 @@ -import { type ChangeEvent } from "react"; -import { X } from "lucide-react"; +import { type ChangeEvent, useState } from "react"; +import { X, ChevronRight, ChevronDown } from "lucide-react"; import { useEditor, useSelectedEntity } from "./useEditor"; import { SchemaField } from "./fields/SchemaField"; import { getDefault } from "../engine/schema"; @@ -11,19 +11,26 @@ type ComponentKey = keyof typeof renderingRegistry; export function Inspector() { const { world } = useEditor(); const selectedEntity = useSelectedEntity(); + const [collapsed, setCollapsed] = useState>(() => { + const initial: Record = {}; + for (const key of Object.keys(world.registry)) { + if (key !== "Transform3D") initial[key] = true; + } + return initial; + }); if (selectedEntity === null) { return ( -
- No entity selected +
+ No entity selected
); } if (!world.isAlive(selectedEntity)) { return ( -
- Entity not alive +
+ Entity not alive
); } @@ -49,7 +56,7 @@ export function Inspector() { } else { const type = raw.slice(0, sepIdx) as ComponentKey; const variant = raw.slice(sepIdx + 2); - const schema = world.registry[type] as TaggedUnionSchema; + const schema = world.registry[type] as unknown as TaggedUnionSchema; const variantSchema = schema.variants[variant]; world.setComponent(selectedEntity!, type, getDefault(variantSchema) as never); } @@ -61,46 +68,69 @@ export function Inspector() { return (
-
- Inspector — Entity {selectedEntity} + {/* Panel header */} +
+ Inspector + #{selectedEntity}
+ {componentTypes.map((type) => { const schema = world.registry[type] as SchemaLike; const data = world.getComponent(selectedEntity, type); + const isCollapsed = collapsed[type]; return (
-
- {type} + {/* Component header */} +
+ setCollapsed((prev) => ({ ...prev, [type]: !prev[type] })) + } + > + + {isCollapsed ? ( + + ) : ( + + )} + {type} +
-
- { - world.setComponent(selectedEntity, type, newValue as never); - }} - /> -
+ {!isCollapsed && ( +
+ { + world.setComponent(selectedEntity, type, newValue as never); + }} + /> +
+ )}
); })} + {availableComponents.length > 0 && ( -
+
onChange(e.target.value)} + className="w-5 h-5 rounded border border-subtle cursor-pointer bg-transparent p-0 shrink-0" + /> + {token.label} + {value} + + ); +} + +function PxRow({ token, value, onChange }: { token: TokenDef; value: string; onChange: (v: string) => void }) { + const numVal = parseFloat(value) || 0; + return ( + + ); +} + +// ── Trigger button (rendered in title bar) ──────────────────────── + +export function ThemeTweakerButton({ open, onToggle }: { open: boolean; onToggle: () => void }) { + return ( + + ); +} + +// ── Docked panel (rendered inside the layout) ───────────────────── + +export function ThemeTweakerPanel({ onClose, activeTheme }: { onClose: () => void; activeTheme: "dark" | "light" }) { + const [values, setValues] = useState>({}); + const [copied, setCopied] = useState(false); + + const initValues = useCallback(() => { + const v: Record = {}; + for (const group of TOKEN_GROUPS) { + for (const token of group.tokens) { + v[token.var] = getCurrentValue(token, activeTheme); + } + } + setValues(v); + }, [activeTheme]); + + useEffect(() => { + initValues(); + }, [initValues]); + + function handleChange(token: TokenDef, value: string) { + setValues((prev) => ({ ...prev, [token.var]: value })); + applyValue(token, value); + } + + function handleReset() { + for (const group of TOKEN_GROUPS) { + for (const token of group.tokens) { + document.documentElement.style.removeProperty(token.var); + } + } + initValues(); + } + + function handleCopyCSS() { + const lines: string[] = []; + for (const group of TOKEN_GROUPS) { + lines.push(` /* ${group.label} */`); + for (const token of group.tokens) { + const val = values[token.var] ?? getDefault(token, activeTheme); + const cssVal = token.type === "px" ? val + "px" : val; + lines.push(` ${token.var}: ${cssVal};`); + } + lines.push(""); + } + const css = `@theme {\n${lines.join("\n")}}`; + navigator.clipboard.writeText(css).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + } + + const hasOverrides = TOKEN_GROUPS.some((g) => + g.tokens.some((t) => { + const cur = values[t.var]; + return cur !== undefined && cur !== getDefault(t, activeTheme); + }) + ); + + return ( +
+ {/* Header */} +
+ Theme +
+ + + +
+
+ + {/* Scrollable content */} +
+ {TOKEN_GROUPS.map((group) => ( +
+
+ {group.label} +
+
+ {group.tokens.map((token) => { + const val = values[token.var] ?? getDefault(token, activeTheme); + const isModified = val !== getDefault(token, activeTheme); + return ( +
+ {token.type === "color" ? ( + handleChange(token, v)} /> + ) : ( + handleChange(token, v)} /> + )} + {isModified && ( +
+ )} +
+ ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/src/editor/ThumbnailCache.ts b/src/editor/ThumbnailCache.ts new file mode 100644 index 0000000..2346b8b --- /dev/null +++ b/src/editor/ThumbnailCache.ts @@ -0,0 +1,211 @@ +import * as THREE from "three"; +import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; +import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js"; +import type { ProjectFolder } from "./ProjectFolder"; +import { inferAssetType } from "./assetTypeDetection"; + +type ThumbnailEntry = { + dataUrl: string | null; + loading: boolean; +}; + +const IMAGE_SIZE = 48; +const MODEL_SIZE = 96; + +export class ThumbnailCache { + private _cache = new Map(); + private _listeners = new Set<() => void>(); + private _version = 0; + + // Lazily created offscreen renderer shared across all GLB thumbnails + private _offscreenRenderer: THREE.WebGLRenderer | null = null; + private _gltfLoader: GLTFLoader | null = null; + + constructor(private _projectFolder: ProjectFolder) {} + + getThumbnail(relativePath: string): string | null { + const existing = this._cache.get(relativePath); + if (existing) return existing.dataUrl; + + // Start async generation + this._cache.set(relativePath, { dataUrl: null, loading: true }); + this._generate(relativePath); + return null; + } + + private async _generate(relativePath: string): Promise { + // Yield to avoid notifying during a React render cycle + await Promise.resolve(); + try { + const type = inferAssetType(relativePath); + let dataUrl: string | null = null; + + if (type === "texture") { + dataUrl = await this._generateImageThumbnail(relativePath); + } else if (type === "glb") { + dataUrl = await this._generateModelThumbnail(relativePath); + } + + this._cache.set(relativePath, { dataUrl, loading: false }); + this._notify(); + } catch { + this._cache.set(relativePath, { dataUrl: null, loading: false }); + this._notify(); + } + } + + private async _generateImageThumbnail(relativePath: string): Promise { + const file = await this._projectFolder.getFile(relativePath); + const blobUrl = URL.createObjectURL(file); + + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = IMAGE_SIZE; + canvas.height = IMAGE_SIZE; + const ctx = canvas.getContext("2d")!; + + // Fit image into square, centered + const scale = Math.min(IMAGE_SIZE / img.width, IMAGE_SIZE / img.height); + const w = img.width * scale; + const h = img.height * scale; + const x = (IMAGE_SIZE - w) / 2; + const y = (IMAGE_SIZE - h) / 2; + + ctx.drawImage(img, x, y, w, h); + URL.revokeObjectURL(blobUrl); + resolve(canvas.toDataURL("image/png")); + }; + img.onerror = () => { + URL.revokeObjectURL(blobUrl); + reject(new Error("Failed to load image")); + }; + img.src = blobUrl; + }); + } + + private _getOffscreenRenderer(): THREE.WebGLRenderer { + if (!this._offscreenRenderer) { + const canvas = document.createElement("canvas"); + canvas.width = MODEL_SIZE; + canvas.height = MODEL_SIZE; + this._offscreenRenderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: true, + preserveDrawingBuffer: true, + }); + this._offscreenRenderer.setSize(MODEL_SIZE, MODEL_SIZE, false); + this._offscreenRenderer.setClearColor(0x000000, 0); + } + return this._offscreenRenderer; + } + + private _getGltfLoader(): GLTFLoader { + if (!this._gltfLoader) { + const draco = new DRACOLoader(); + draco.setDecoderPath( + "https://www.gstatic.com/draco/versioned/decoders/1.5.7/", + ); + this._gltfLoader = new GLTFLoader(); + this._gltfLoader.setDRACOLoader(draco); + } + return this._gltfLoader; + } + + private async _generateModelThumbnail(relativePath: string): Promise { + const file = await this._projectFolder.getFile(relativePath); + const blobUrl = URL.createObjectURL(file); + + try { + const gltf = await new Promise((resolve, reject) => { + this._getGltfLoader().load( + blobUrl, + (result) => resolve(result.scene), + undefined, + (err) => reject(err instanceof Error ? err : new Error(String(err))), + ); + }); + + const renderer = this._getOffscreenRenderer(); + + // Build a tiny scene + const scene = new THREE.Scene(); + scene.add(gltf); + + // Lights + const ambient = new THREE.AmbientLight(0xffffff, 0.6); + const dir = new THREE.DirectionalLight(0xffffff, 1.2); + dir.position.set(1, 2, 3); + scene.add(ambient, dir); + + // Auto-frame camera from bounding box + const box = new THREE.Box3().setFromObject(gltf); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z) || 1; + + const camera = new THREE.PerspectiveCamera(45, 1, 0.01, maxDim * 10); + const dist = maxDim / (2 * Math.tan((Math.PI * 45) / 360)); + camera.position.set( + center.x + dist * 0.7, + center.y + dist * 0.5, + center.z + dist, + ); + camera.lookAt(center); + + renderer.render(scene, camera); + const dataUrl = renderer.domElement.toDataURL("image/png"); + + // Dispose the loaded model + gltf.traverse((node) => { + if ((node as any).geometry) (node as any).geometry.dispose(); + if ((node as any).material) { + const mats = Array.isArray((node as any).material) + ? (node as any).material + : [(node as any).material]; + for (const m of mats) { + if (m.map) m.map.dispose(); + m.dispose(); + } + } + }); + + return dataUrl; + } finally { + URL.revokeObjectURL(blobUrl); + } + } + + invalidate(relativePath: string): void { + this._cache.delete(relativePath); + this._notify(); + } + + clear(): void { + this._cache.clear(); + this._notify(); + } + + dispose(): void { + this._offscreenRenderer?.dispose(); + this._offscreenRenderer = null; + this._cache.clear(); + } + + // useSyncExternalStore integration + subscribe = (cb: () => void): (() => void) => { + this._listeners.add(cb); + return () => this._listeners.delete(cb); + }; + + getSnapshot = (): number => { + return this._version; + }; + + private _notify(): void { + this._version++; + for (const cb of this._listeners) cb(); + } +} diff --git a/src/editor/Toolbar.tsx b/src/editor/Toolbar.tsx index c9deeba..10add08 100644 --- a/src/editor/Toolbar.tsx +++ b/src/editor/Toolbar.tsx @@ -1,6 +1,6 @@ -import { useRef } from "react"; -import { Save, FolderOpen, Focus } from "lucide-react"; -import { useEditor, useSelectedEntity } from "./useEditor"; +import { useEffect, useRef } from "react"; +import { Save, SaveAll, FolderOpen, Focus, FolderRoot, FolderSync } from "lucide-react"; +import { useEditor, useSelectedEntity, useProjectFolder } from "./useEditor"; import { worldToJson } from "../engine/ecs/bridge"; import { parseWorld } from "../engine/world"; import { renderingRegistry } from "../engine/rendering/components"; @@ -8,45 +8,126 @@ import type { ComponentType, ComponentData } from "../engine/ecs/types"; type Registry = typeof renderingRegistry; +function ToolbarButton({ + onClick, + disabled, + title, + active, + children, +}: { + onClick: () => void; + disabled?: boolean; + title: string; + active?: boolean; + children: React.ReactNode; +}) { + return ( + + ); +} + +function ToolbarSeparator() { + return
; +} + export function Toolbar() { const { store, world, hierarchy, binding, controls } = useEditor(); const selectedEntity = useSelectedEntity(); - const fileInputRef = useRef(null); + const projectFolder = useProjectFolder(); - function handleSave() { - const { json } = worldToJson(renderingRegistry, world, { hierarchy }); - const blob = new Blob([JSON.stringify(json, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = "scene.json"; - anchor.click(); - URL.revokeObjectURL(url); - } + const didAutoLoad = useRef(false); + useEffect(() => { + if (didAutoLoad.current) return; + didAutoLoad.current = true; + const lastId = store.getLastSceneId(); + if (lastId) { + openSceneById(lastId).catch(() => {}); + } + }, []); - function handleLoad() { - fileInputRef.current?.click(); + async function openSceneById(id: string) { + const res = await fetch(`/api/scenes/${id}`); + if (!res.ok) return; + const scene = await res.json(); + loadScene(scene.data); + store.setScene(scene.id, scene.name); } - function handleFileChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; + function getSceneContents() { + const { json } = worldToJson(renderingRegistry, world, { hierarchy }); + return json; + } - const reader = new FileReader(); - reader.onload = () => { + async function handleSave() { + const sceneId = store.getSceneId(); + if (sceneId) { try { - const json = JSON.parse(reader.result as string); - loadScene(json); + const data = getSceneContents(); + const name = store.getSceneName() ?? "Scene"; + await fetch(`/api/scenes/${sceneId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, data }), + }); + return; } catch (err) { - console.error("Failed to load scene:", err); + console.error("Failed to save scene:", err); } - }; - reader.readAsText(file); + } + await handleSaveAs(); + } + + async function handleSaveAs() { + const name = window.prompt("Scene name:", "Untitled Scene"); + if (!name) return; + + try { + const data = getSceneContents(); + const res = await fetch("/api/scenes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, data }), + }); + if (!res.ok) throw new Error("Failed to create scene"); + const created = await res.json(); + store.setScene(created.id, name); + } catch (err) { + console.error("Failed to save scene:", err); + } + } + + async function handleLoad() { + try { + const res = await fetch("/api/scenes"); + if (!res.ok) throw new Error("Failed to fetch scene list"); + const scenes: { id: string; name: string }[] = await res.json(); + if (scenes.length === 0) { + window.alert("No saved scenes found."); + return; + } + + const listStr = scenes.map((s, i) => `${i + 1}. ${s.name}`).join("\n"); + const choice = window.prompt(`Pick a scene (1-${scenes.length}):\n${listStr}`); + if (!choice) return; + const idx = parseInt(choice, 10) - 1; + if (isNaN(idx) || idx < 0 || idx >= scenes.length) return; - // Reset the input so the same file can be loaded again - e.target.value = ""; + await openSceneById(scenes[idx].id); + } catch (err) { + console.error("Failed to load scene:", err); + } } function loadScene(json: unknown) { @@ -98,40 +179,44 @@ export function Toolbar() { } return ( -
- - - - + + + {projectFolder.isOpen && ( + projectFolder.close()} + title="Disconnect" + > + + + )}
); } diff --git a/src/editor/Viewport.tsx b/src/editor/Viewport.tsx index 7393bcb..6635428 100644 --- a/src/editor/Viewport.tsx +++ b/src/editor/Viewport.tsx @@ -1,6 +1,7 @@ -import { useRef, useEffect, useCallback } from "react"; +import { useRef, useEffect, useCallback, useState } from "react"; import type { EditorSession } from "./setup"; import { pickEntity } from "./viewportRaycast"; +import { inferAssetType } from "./assetTypeDetection"; type Props = { session: EditorSession; @@ -11,6 +12,7 @@ export function Viewport({ session }: Props) { const canvasRef = useRef( session.renderer.domElement ); + const [dragOver, setDragOver] = useState(false); const resize = useCallback(() => { const container = containerRef.current; @@ -111,10 +113,99 @@ export function Viewport({ session }: Props) { }; }, [session, resize]); + const handleViewportDragOver = (e: React.DragEvent) => { + if ( + e.dataTransfer.types.includes("application/x-fnayr-asset") || + e.dataTransfer.types.includes("Files") + ) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setDragOver(true); + } + }; + + const handleViewportDragLeave = () => setDragOver(false); + + const handleViewportDrop = async (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + + let uri: string | null = null; + let type: string | null = null; + let name = "Imported Asset"; + + // Internal asset browser drop + const assetData = e.dataTransfer.getData("application/x-fnayr-asset"); + if (assetData) { + try { + const parsed = JSON.parse(assetData) as { path: string; type: string | null }; + uri = parsed.path; + type = parsed.type; + const parts = parsed.path.split("/"); + name = parts[parts.length - 1]; + } catch {} + } + + // External file drop from OS + if (!uri) { + const file = e.dataTransfer.files?.[0]; + if (!file) return; + type = inferAssetType(file.name); + name = file.name; + + if (session.projectFolder.isOpen) { + try { + uri = await session.projectFolder.importFile(file); + } catch (err) { + console.warn("Failed to import dropped file:", err); + } + } + if (!uri) { + uri = URL.createObjectURL(file); + } + } + + if (!uri) return; + + const { world, store } = session; + const entity = world.createEntity(); + world.setComponent(entity, "Meta", { name }); + world.setComponent(entity, "Transform3D", { + position: [0, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }); + + if (type === "glb") { + world.setComponent(entity, "ModelVisual", { + asset: { kind: "asset", type: "glb", uri, sub: "", options: {} }, + }); + } else if (type === "texture") { + world.setComponent(entity, "MeshVisual", { + geometry: { kind: "plane", width: 2, height: 2 }, + color: [1, 1, 1, 1], + texture: { kind: "asset", type: "texture", uri, sub: "", options: {} }, + }); + } + + store.selectEntity(entity); + }; + return (
+ className="flex-1 min-w-0 min-h-0 relative bg-editor-bg" + onDragOver={handleViewportDragOver} + onDragLeave={handleViewportDragLeave} + onDrop={handleViewportDrop} + > + {dragOver && ( +
+
+ Drop asset here +
+
+ )} +
); } diff --git a/src/editor/assetTypeDetection.test.ts b/src/editor/assetTypeDetection.test.ts new file mode 100644 index 0000000..0a4916f --- /dev/null +++ b/src/editor/assetTypeDetection.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { inferAssetType, isTypeCompatible } from "./assetTypeDetection"; + +describe("inferAssetType", () => { + it("maps image extensions to texture", () => { + expect(inferAssetType("brick.png")).toBe("texture"); + expect(inferAssetType("photo.jpg")).toBe("texture"); + expect(inferAssetType("icon.webp")).toBe("texture"); + }); + + it("maps model extensions to glb", () => { + expect(inferAssetType("robot.glb")).toBe("glb"); + expect(inferAssetType("scene.gltf")).toBe("glb"); + }); + + it("maps video extensions to videoClip", () => { + expect(inferAssetType("clip.mp4")).toBe("videoClip"); + expect(inferAssetType("intro.webm")).toBe("videoClip"); + }); + + it("maps audio extensions to audioClip", () => { + expect(inferAssetType("song.mp3")).toBe("audioClip"); + expect(inferAssetType("effect.wav")).toBe("audioClip"); + expect(inferAssetType("music.ogg")).toBe("audioClip"); + }); + + it("is case insensitive", () => { + expect(inferAssetType("Brick.PNG")).toBe("texture"); + expect(inferAssetType("Robot.GLB")).toBe("glb"); + expect(inferAssetType("Song.MP3")).toBe("audioClip"); + }); + + it("returns null for unknown extensions", () => { + expect(inferAssetType("readme.txt")).toBeNull(); + expect(inferAssetType("data.json")).toBeNull(); + }); + + it("returns null for files with no extension", () => { + expect(inferAssetType("Makefile")).toBeNull(); + }); +}); + +describe("isTypeCompatible", () => { + it("returns true when types match", () => { + expect(isTypeCompatible("texture", "texture")).toBe(true); + expect(isTypeCompatible("glb", "glb")).toBe(true); + }); + + it("returns false when types mismatch", () => { + expect(isTypeCompatible("texture", "glb")).toBe(false); + expect(isTypeCompatible("glb", "audioClip")).toBe(false); + }); + + it("returns true when inferred is null (unknown extension)", () => { + expect(isTypeCompatible("texture", null)).toBe(true); + }); +}); diff --git a/src/editor/assetTypeDetection.ts b/src/editor/assetTypeDetection.ts new file mode 100644 index 0000000..83a478d --- /dev/null +++ b/src/editor/assetTypeDetection.ts @@ -0,0 +1,44 @@ +export type AssetType = "texture" | "glb" | "videoClip" | "audioClip"; + +const EXT_MAP: Record = { + // Textures + ".png": "texture", + ".jpg": "texture", + ".jpeg": "texture", + ".webp": "texture", + ".gif": "texture", + ".bmp": "texture", + ".svg": "texture", + ".tga": "texture", + ".exr": "texture", + ".hdr": "texture", + // 3D models + ".glb": "glb", + ".gltf": "glb", + // Video + ".mp4": "videoClip", + ".webm": "videoClip", + ".mov": "videoClip", + ".avi": "videoClip", + // Audio + ".mp3": "audioClip", + ".wav": "audioClip", + ".ogg": "audioClip", + ".flac": "audioClip", + ".aac": "audioClip", +}; + +export function inferAssetType(filename: string): AssetType | null { + const dot = filename.lastIndexOf("."); + if (dot < 0) return null; + const ext = filename.slice(dot).toLowerCase(); + return EXT_MAP[ext] ?? null; +} + +export function isTypeCompatible( + expected: AssetType | string, + inferred: AssetType | null, +): boolean { + if (!inferred) return true; // unknown extension, allow + return expected === inferred; +} diff --git a/src/editor/fields/AssetRefField.tsx b/src/editor/fields/AssetRefField.tsx new file mode 100644 index 0000000..c7d728a --- /dev/null +++ b/src/editor/fields/AssetRefField.tsx @@ -0,0 +1,195 @@ +import { useRef, useState } from "react"; +import type { ObjectSchema, SchemaLike } from "../../engine/schema"; +import { SchemaField } from "./SchemaField"; +import { useProjectFolder, useThumbnailCache } from "../useEditor"; +import { inferAssetType, isTypeCompatible } from "../assetTypeDetection"; + +type Props = { + value: Record; + schema: ObjectSchema; + onChange: (value: Record) => void; +}; + +const acceptByType: Record = { + texture: "image/*", + glb: ".glb,.gltf", + videoClip: "video/*", + audioClip: "audio/*", +}; + +export function AssetRefField({ value, schema, onChange }: Props) { + const fileInputRef = useRef(null); + const projectFolder = useProjectFolder(); + const thumbnailCache = useThumbnailCache(); + const [dragOver, setDragOver] = useState(false); + const [typeWarning, setTypeWarning] = useState(null); + const [importing, setImporting] = useState(false); + + const assetType = value.type as string | undefined; + const accept = assetType ? acceptByType[assetType] : undefined; + + function checkTypeWarning(filename: string): boolean { + if (!assetType) return false; + const inferred = inferAssetType(filename); + if (!isTypeCompatible(assetType, inferred)) { + setTypeWarning(`Expected ${assetType}, got ${inferred}`); + return true; + } + setTypeWarning(null); + return false; + } + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + checkTypeWarning(file.name); + + try { + const relativePath = await projectFolder.importFile(file); + onChange({ ...value, uri: relativePath }); + } catch (err) { + console.warn("Failed to import file:", err); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + if ( + e.dataTransfer.types.includes("application/x-fnayr-asset") || + e.dataTransfer.types.includes("Files") + ) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setDragOver(true); + } + }; + + const handleDragLeave = () => setDragOver(false); + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + + // Internal asset browser drop + const assetData = e.dataTransfer.getData("application/x-fnayr-asset"); + if (assetData) { + try { + const { path } = JSON.parse(assetData) as { path: string; type: string | null }; + checkTypeWarning(path); + onChange({ ...value, uri: path }); + return; + } catch {} + } + + // External file drop from OS + const file = e.dataTransfer.files?.[0]; + if (file) { + checkTypeWarning(file.name); + + try { + const relativePath = await projectFolder.importFile(file); + onChange({ ...value, uri: relativePath }); + } catch (err) { + console.warn("Failed to import dropped file:", err); + } + } + }; + + function isExternalUrl(s: string): boolean { + return s.startsWith("http://") || s.startsWith("https://"); + } + + const importExternalUrl = async (url: string) => { + if (!isExternalUrl(url) || !projectFolder.isOpen) return; + setImporting(true); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`Fetch failed: ${res.status}`); + const blob = await res.blob(); + const filename = url.split("/").pop()?.split("?")[0] || "asset"; + const file = new File([blob], filename, { type: blob.type }); + checkTypeWarning(filename); + const relativePath = await projectFolder.importFile(file); + onChange({ ...value, uri: relativePath }); + } catch (err) { + console.warn("Failed to import external URL:", err); + } finally { + setImporting(false); + } + }; + + const uri = (value.uri as string) ?? ""; + const thumb = uri && projectFolder.isOpen ? thumbnailCache.getThumbnail(uri) : null; + + return ( +
+ {/* URI field with browse button */} +
+
+ uri + {thumb && } +
+
+ { + setTypeWarning(null); + onChange({ ...value, uri: e.target.value }); + }} + onBlur={() => importExternalUrl(uri)} + onKeyDown={(e) => { + if (e.key === "Enter") importExternalUrl(uri); + }} + className="bg-input border border-subtle rounded px-1.5 py-1 w-full text-primary outline-none focus:border-focus hover:border-border min-w-0 text-body" + /> + + +
+ {importing && ( +
Importing...
+ )} + {typeWarning && ( +
{typeWarning}
+ )} +
+ + {/* Remaining fields (skip literal types and hidden fields, same as ObjectField) */} + {Object.entries(schema.properties) + .filter( + ([key, propSchema]) => + key !== "uri" && + propSchema.type !== "literal" && + !propSchema.meta?.hidden + ) + .map(([key, propSchema]) => ( +
+
{key}
+ onChange({ ...value, [key]: v })} + /> +
+ ))} +
+ ); +} diff --git a/src/editor/fields/ColorField.tsx b/src/editor/fields/ColorField.tsx index db16220..256dbe7 100644 --- a/src/editor/fields/ColorField.tsx +++ b/src/editor/fields/ColorField.tsx @@ -16,26 +16,29 @@ export function ColorField({ value, schema, onChange }: Props) { const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; return ( -
- { - const h = e.target.value; - const nr = parseInt(h.slice(1, 3), 16) / 255; - const ng = parseInt(h.slice(3, 5), 16) / 255; - const nb = parseInt(h.slice(5, 7), 16) / 255; - if (hasAlpha) { - onChange([nr, ng, nb, value[3]]); - } else { - onChange([nr, ng, nb]); - } - }} - className="w-6 h-6 rounded border border-subtle cursor-pointer bg-transparent p-0 shrink-0" - /> +
+
+ { + const h = e.target.value; + const nr = parseInt(h.slice(1, 3), 16) / 255; + const ng = parseInt(h.slice(3, 5), 16) / 255; + const nb = parseInt(h.slice(5, 7), 16) / 255; + if (hasAlpha) { + onChange([nr, ng, nb, value[3]]); + } else { + onChange([nr, ng, nb]); + } + }} + className="w-7 h-7 rounded border border-subtle cursor-pointer bg-transparent p-0 shrink-0" + /> +
+ {hex} {hasAlpha && ( <> - A + A - + {Math.round(value[3] * 100)}% diff --git a/src/editor/fields/DraggableNumber.tsx b/src/editor/fields/DraggableNumber.tsx index abd35f2..5b76b50 100644 --- a/src/editor/fields/DraggableNumber.tsx +++ b/src/editor/fields/DraggableNumber.tsx @@ -22,6 +22,13 @@ function format(v: number) { return Number.isInteger(v) ? v.toString() : parseFloat(v.toFixed(3)).toString(); } +const LABEL_COLORS: Record = { + X: "text-red-400/80", + Y: "text-green-400/80", + Z: "text-blue-400/80", + W: "text-purple-400/80", +}; + export function DraggableNumber({ value, schema, label, onChange, speed = DRAG_SPEED }: DraggableNumberProps) { const [editing, setEditing] = useState(false); const [editText, setEditText] = useState(""); @@ -94,10 +101,12 @@ export function DraggableNumber({ value, schema, label, onChange, speed = DRAG_S } }; + const labelColor = LABEL_COLORS[label] || "text-muted"; + if (editing) { return ( ); @@ -119,7 +128,7 @@ export function DraggableNumber({ value, schema, label, onChange, speed = DRAG_S return (