diff --git a/CLAUDE.md b/CLAUDE.md index fd81eaa9..c32e584c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,7 @@ Before marking any task as done, run the following checks and fix any failures: - Treat `127.0.0.1:6767` as production by default; do not stop or kill the existing production server for ad-hoc testing. - When backend changes need local validation, use `repo_dev_up` and point validation tooling to the printed URL. - Only operate on production (`:6767`) when explicitly requested by the user. +- Scoped MCP routes (`/api/mcp/:agentId`, `/api/mcp/jobs/:runId/:agentId`) 403 on the server bearer token — they accept only agent-scoped HMAC tokens or no token. Tests and local tooling must call them with no Authorization header. ## Development Database diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..9c5a2794 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,139 @@ +# Whiteboard Integration Plan + +> **Status (2026-07-07): MVP (Phases 1+2) + Phase 3 (agent drawing) implemented and validated.** +> Board draws/persists/reloads; agent sees it via `whiteboard_get` + PNG snapshot and +> draws back via `whiteboard_update` (server-side builder, live sync, "agent drew" tab dot). +> Environment surprises + insinuated decisions: see `.unknowns/journal.md` (gitignored, local). + +A shared, agent-interactive whiteboard (Excalidraw-based) as a third center-pane tab +alongside **Terminal** and **Changes**. The user draws; the agent can _see_ the board +(scene JSON + rendered PNG snapshot) and _draw back_ via MCP tools, enabling a visual +back-and-forth about architecture and design. + +## Decisions (flagged assumptions — cheap to veto now) + +| # | Decision | Rationale / alternative | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| D1 | **Excalidraw** (`@excalidraw/excalidraw`, MIT) | tldraw needs a license key / watermark. User asked for "Excalidraw-esque". | +| D2 | **Board is per-agent** (keyed by `agent_id`) | Matches the tab's location. Repo-scoped shared boards are a natural follow-up (brain-style), not MVP. | +| D3 | **Scene JSON is the source of truth, stored server-side** (Postgres `jsonb`) | Survives reloads; agent can read/write with no browser attached. | +| D4 | **Agent sees the board two ways**: simplified scene JSON _and_ a PNG snapshot exported by the client on save | JSON alone is hard to interpret for freeform sketches; PNG leverages vision. ~~Snapshot reuses the existing media pipeline~~ **Killed by blindspot #2**: media seen-key is `name:updatedAt`, so update-in-place re-badges unseen on every save. Store snapshot on the whiteboard row / dedicated endpoint instead. | +| D5 | **Agent draws via a simplified element schema**, expanded server-side by our own builder (~200 LOC) — not by importing Excalidraw's browser-only `convertToExcalidrawElements` | Raw Excalidraw elements are verbose and fragile (seeds, versionNonce, bindings). Browser-lib-on-server is a trap. **Spike this first** (see risks). | +| D6 | **Merge, don't lock**: element-level last-writer-wins using Excalidraw's built-in `version`/`versionNonce` reconciliation | Excalidraw elements are designed for this; full CRDT is overkill for 1 human + 1 agent. | +| D7 | Agent is only _prompted_ when the user explicitly asks (send-to-agent button or typing in terminal) | No auto-interrupt per stroke; agent pulls board state when it wants via MCP. | + +## Grilled decisions (2026-07-07, user-confirmed) + +| # | Decision | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| G1 | **Snapshot = plain file on disk**: server writes `whiteboard.png` into the agent's media dir with **no `media` DB row** — agent vision-Reads the path from `whiteboard_get`; invisible to media list/badge. Verify while building that media listing is DB-backed (exclude by filename if any disk-listing path exists). | +| G2 | **Keep-mounted once opened** (Terminal pattern, deviates from route-mounted Changes copy): lazy-mount Excalidraw on first visit to `/agents/:id/whiteboard`, then CSS-hide on tab switch. Undo/zoom/unsaved strokes survive in memory; no localStorage view-state machinery. | +| G3 | **Tool access**: `AGENT_TOOLS` get `whiteboard_get` + `whiteboard_update`; `PERSONA_TOOLS` get `whiteboard_get` only; `JOB_TOOLS` get neither. | +| G4 | **Draw schema**: simplified ops only (add/update/delete over rect/ellipse/diamond/arrow/line/text/frame with label/from/to/color). No raw-element passthrough, no mermaid in MVP. | +| G5 | **Live sync**: agent edits auto-apply via `updateScene()`, queued while the user's pointer is down and flushed on pointer-up; subtle "agent drew" indicator. | +| G6 | **Ping path**: "Ask agent" button with optional note → injects a DISPATCH MESSAGE envelope telling the agent to call `whiteboard_get` (pull-based; no scene JSON in the prompt). | +| G7 | **Delivery**: PR 1 = Phases 1+2 (MVP: board + agent can _view_, incl. spikes). Later PRs: agent drawing (Phase 3), ask-button/polish (Phase 4). | + +## Architecture + +``` +┌─ web (React) ────────────────┐ ┌─ server (Fastify) ─────────────┐ +│ WhiteboardTab (lazy) │ PUT │ /agents/:id/whiteboard │ +│ └ │───────▶│ reconcile + save (jsonb) │ +│ debounced save + PNG export │ │ publish SSE whiteboard.changed │ +│ SSE → updateScene() │◀───────│ │ +└──────────────────────────────┘ SSE │ MCP: whiteboard_get / │ + │ whiteboard_update ◀────────┼── agent + └─────────────────────────────────┘ +``` + +## Phase 1 — Whiteboard tab + persistence (no agent yet) + +**Frontend** (patterns from the Changes tab): + +- `center-pane-tab-bar.tsx:6` — extend `CenterTab` union with `"whiteboard"`, add tab button. +- `use-agents-view-routing.ts` — add `useMatch("/agents/:agentId/whiteboard")` + nav branch; helper in `lib/agent-routes.ts`. +- `agents-view.tsx` (~563–595) — mount as a nested `` like ChangesTab. +- New `components/app/whiteboard-tab.tsx`, **`React.lazy`-loaded on first visit, then kept mounted + CSS-hidden** (Terminal pattern per G2, not route-mounted like Changes). +- Load scene via React Query (`GET /agents/:id/whiteboard`), save with ~1s debounced `PUT` on `onChange`. +- Theme: pass app dark/light to Excalidraw's `theme` prop. + +**Backend**: + +- Migration `0029_whiteboards.sql`: `whiteboards(agent_id PK, scene jsonb, version bigint, updated_by text, updated_at)`. +- Routes: `GET`/`PUT /api/v1/agents/:id/whiteboard` (PUT takes `{elements, baseVersion}`; reconciles by element `version`/`versionNonce`, bumps board version). +- New `UiEvent`: `whiteboard.changed {agentId, version, source: "user"|"agent"}` in `ui-events.ts`; client handler in `use-sse.ts` → refetch + `excalidrawAPI.updateScene()` when `source === "agent"`. + +## Phase 2 — Agent can see the board (ships with Phase 1 as the MVP PR, per G7) + +- Client exports a PNG (`exportToBlob`) on save (heavier debounce, ~5s idle) and POSTs it separately (not inside the JSON PUT — bodyLimit); server writes `whiteboard.png` into the agent's media dir **without a `media` DB row** (G1) → agent vision-`Read`s it, media badge untouched. +- New MCP tool `whiteboard_get` (add to `AGENT_TOOLS` in `shared/mcp/server.ts:82`; new registrar `whiteboard-tools.ts`): returns simplified scene JSON (ids, types, positions, text, arrow endpoints — style noise stripped) + the snapshot file path. + +## Phase 3 — Agent can draw + +### Product pass (2026-07-07) — what "drawing well" means + +Directions artifact (4 options, steal/skip picks): https://claude.ai/code/artifact/a2ffacda-cf55-436c-a16c-a48b1e5b790e +Recommended + building against (veto via artifact): **Signature Ink with the Annotator's manners**. + +User stories: + +1. _Annotate mine_: I sketch services and ask "which of these talk to Postgres?" — + the agent draws bound arrows + short labels pointing at MY boxes, near them, never over them. +2. _Draw fresh_: I ask "draw the auth flow of this repo" — the agent lays out a + boxes-and-arrows diagram in empty board space; I rearrange it and the agent sees my + corrections on the next `whiteboard_get`. +3. _Critique visually_: I ask for a review of my architecture sketch — the agent circles + the problem area (dashed ellipse) and leaves a short margin note with a connector arrow, + instead of a wall of terminal text. +4. _Away from the tab_: the agent draws while I'm on Terminal — the whiteboard tab shows a + badge; when I click through, the agent's ink is visually distinct so I see what's new. +5. _Iterate_: the agent refines its own diagram (move/relabel/delete by id) without + duplicating elements — the tool result echoes created ids so it can keep working. + +Product decisions (P-series, this session — flagged, cheap to veto): + +| # | Decision | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P1 | **Soft attribution**: agent strokes default to Excalidraw violet (`#6741d9`) + `customData.author: "agent"` stamped on elements. No hard UI layer; user restyles freely. | +| P2 | **Etiquette in the tool description**: place near, never cover; annotate by pointing (arrow + short label); prefer updating own elements by id over redrawing. | +| P3 | **Tool result echoes the board**: `whiteboard_update` returns created ids + new version (and errors per-op), so the agent iterates without a second `whiteboard_get`. Agent may supply its own readable ids on add (needed for same-call arrow bindings). | +| P4 | **Stale snapshot handled two ways**: `whiteboard_get` marks `snapshotStale: true` when scene `updated_at` > PNG mtime; an open client re-exports the PNG after applying a remote agent scene (closing the loop when a browser is attached). | + +### Build + +- MCP tool `whiteboard_update`: ops `add | update | delete` over a **simplified schema**: + `{type: rect|ellipse|diamond|arrow|line|text|frame, id?, x, y, w, h, label?, from?, to?, color?}` + (`from`/`to` are element ids → server creates proper arrow bindings). +- Server-side builder expands ops into valid Excalidraw elements (id/seed/versionNonce/boundElements), merges into the scene, bumps versions, publishes `whiteboard.changed(source: agent)` → open clients update live. +- "Agent drew" tab indicator (G5 minimum) — mirror the diff-stats badge in `center-pane-tab-bar.tsx`. +- Round-trip vitest: builder output → `restoreElements` in an E2E to prove Excalidraw accepts it. + +## Phase 4 — Conversational loop polish + +- "Ask agent" button on the whiteboard: injects a prompt via the existing injector (`agent-prompts.ts:10`, tmux bracketed paste) with a `--- DISPATCH MESSAGE ---` envelope: _"the user updated the whiteboard, see whiteboard_get"_ + optional user note. +- Agent-drawing indicator (small badge on the tab when a `whiteboard.changed(agent)` event arrives while on another tab — mirror the diff-stats badge). +- Tool-usage guidance in the agent system prompt/CLAUDE.md so agents know the whiteboard exists. +- Later / out of scope for now: repo-scoped shared boards, multi-user cursors, mermaid→board import, board history/versions. + +## Risks & spikes (do first) + +1. **Spike A (Phase 3 risk, ~half day):** hand-generate Excalidraw elements server-side (incl. arrow bindings + text containers) and confirm the editor accepts them cleanly. If fragile, fallback: client-side conversion of queued agent ops using `convertToExcalidrawElements` (requires an open client; ops queue when none attached). +2. **Spike B:** confirm Excalidraw plays well with **React 18.3** (app is not on 19) + Vite 5, measure chunk size, and check whether it fetches fonts/assets from a CDN at runtime (`EXCALIDRAW_ASSET_PATH` + embedded runtime-assets codegen if so). +3. Element-level LWW can drop a concurrent user edit to the _same_ element the agent touched — acceptable for MVP; note in UI ("agent updated the board"). + +## Blindspot pass (2026-07-07) — confirmed constraints to build against + +Full cards: see the "Whiteboard Integration — Blindspots" artifact. Confirmed from code: + +- **Body limit**: Fastify default 1 MB, never overridden (`server.ts:149`). PUT whiteboard needs a per-route `bodyLimit` (~10 MB); send PNG separately/multipart. +- **Snapshot ≠ media pipeline** (kills old D4 detail): store PNG outside the media list or the unseen badge pulses on every save. +- **Tab unmount**: whiteboard tab (Changes pattern) fully unmounts on switch — flush debounced save on unmount; persist zoom/scroll in a localStorage `atomFamily` (`store.ts:208` precedent). +- **SSE reconnect**: connection closes on hidden tab; `snapshot` handler (`use-sse.ts:89`) must also invalidate the whiteboard query. Defer `updateScene()` while pointer is down. +- **Tool gating**: add `whiteboard_get`/`whiteboard_update` to each of `AGENT_TOOLS`/`JOB_TOOLS`/`PERSONA_TOOLS` deliberately — absent = invisible per type. +- **PWA precache**: workbox drops files > 4 MiB silently (`vite.config.ts:81`); this is the app's first `React.lazy` chunk — check built size in `finalize:web`. +- **Lifecycle**: agent deletion is soft (`deleted_at`); CASCADE never fires, no retention job — whiteboard rows/PNGs persist past deletion by design (decided, and it strengthens future repo-scoped boards). + +## Checks per phase + +`pnpm run check` · `pnpm run finalize:web` · `pnpm run test` (builder/reconcile units) · `pnpm run test:e2e` (new spec: open tab, draw rect via pointer events, reload persists; Phase 3: MCP update appears live). diff --git a/apps/server/package.json b/apps/server/package.json index 0e07ae5c..9eb0c989 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -35,6 +35,7 @@ "@types/pg": "^8.18.0", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "4.1.2", + "tsx": "^4.23.0", "vite": "^6.0.0", "vitest": "^4.0.18" } diff --git a/apps/server/src/db/migrations/0029_whiteboards.sql b/apps/server/src/db/migrations/0029_whiteboards.sql new file mode 100644 index 00000000..cc5f60ca --- /dev/null +++ b/apps/server/src/db/migrations/0029_whiteboards.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS whiteboards ( + agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE, + scene JSONB NOT NULL DEFAULT '{"elements": []}'::jsonb, + version BIGINT NOT NULL DEFAULT 1, + updated_by TEXT NOT NULL DEFAULT 'user', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/apps/server/src/routes/mcp.ts b/apps/server/src/routes/mcp.ts index 8569cc28..39a0a60d 100644 --- a/apps/server/src/routes/mcp.ts +++ b/apps/server/src/routes/mcp.ts @@ -49,6 +49,8 @@ type McpRouteDeps = { mcpRenameSession: unknown; mcpShareMedia: unknown; mcpListMedia: unknown; + mcpGetWhiteboard: unknown; + mcpUpdateWhiteboard: unknown; mcpSubmitFeedback: unknown; mcpListPersonas: unknown; mcpLaunchPersona: unknown; @@ -289,6 +291,8 @@ export async function registerMcpRoutes( renameSession: deps.mcpRenameSession, shareMedia: deps.mcpShareMedia, listMedia: deps.mcpListMedia, + getWhiteboard: deps.mcpGetWhiteboard, + updateWhiteboard: deps.mcpUpdateWhiteboard, submitFeedback: deps.mcpSubmitFeedback, listPersonas: deps.mcpListPersonas, launchPersona: deps.mcpLaunchPersona, diff --git a/apps/server/src/routes/whiteboard.ts b/apps/server/src/routes/whiteboard.ts new file mode 100644 index 00000000..4f4d1d9c --- /dev/null +++ b/apps/server/src/routes/whiteboard.ts @@ -0,0 +1,146 @@ +import path from "node:path"; +import { mkdir, rm, writeFile } from "node:fs/promises"; + +import type { FastifyInstance } from "fastify"; +import type { Pool } from "pg"; + +import type { AgentManager } from "../agents/manager.js"; +import { resolveMediaDir } from "../shared/media.js"; +import { + EMPTY_SCENE, + isValidScene, + loadWhiteboard, + MAX_ELEMENTS, + saveWhiteboard, + WHITEBOARD_SNAPSHOT_FILENAME, +} from "../shared/whiteboard-store.js"; + +// Fastify's default JSON bodyLimit is 1 MB; real boards blow past it. +const SCENE_BODY_LIMIT = 8 * 1024 * 1024; + +type WhiteboardRouteDeps = { + pool: Pool; + mediaRoot: string; + agentManager: AgentManager; + publishUiEvent: (event: unknown) => void; +}; + +export async function registerWhiteboardRoutes( + app: FastifyInstance, + deps: WhiteboardRouteDeps +): Promise { + app.get("/api/v1/agents/:id/whiteboard", async (request, reply) => { + const params = request.params as { id?: string }; + const id = params.id ?? ""; + const agent = await deps.agentManager.getAgent(id); + if (!agent) { + return reply.code(404).send({ error: "Agent not found." }); + } + + const row = await loadWhiteboard(deps.pool, id); + if (!row) { + return { scene: EMPTY_SCENE, version: 0, updatedAt: null }; + } + return { + scene: row.scene, + version: Number(row.version), + updatedAt: row.updated_at.toISOString(), + }; + }); + + app.put( + "/api/v1/agents/:id/whiteboard", + { bodyLimit: SCENE_BODY_LIMIT }, + async (request, reply) => { + const params = request.params as { id?: string }; + const id = params.id ?? ""; + const agent = await deps.agentManager.getAgent(id); + if (!agent) { + return reply.code(404).send({ error: "Agent not found." }); + } + + const body = request.body as + | { scene?: unknown; baseVersion?: unknown } + | undefined; + if (!isValidScene(body?.scene)) { + return reply.code(400).send({ + error: `scene must be an object with an elements array (max ${MAX_ELEMENTS}).`, + }); + } + const baseVersion = + typeof body?.baseVersion === "number" && + Number.isInteger(body.baseVersion) && + body.baseVersion >= 0 + ? body.baseVersion + : null; + if (baseVersion === null) { + return reply + .code(400) + .send({ error: "baseVersion must be a non-negative integer." }); + } + + const saved = await saveWhiteboard( + deps.pool, + id, + body.scene, + baseVersion, + "user" + ); + if (!saved) { + const current = await loadWhiteboard(deps.pool, id); + return reply.code(409).send({ + error: "Whiteboard was modified by someone else.", + scene: current?.scene ?? EMPTY_SCENE, + version: current ? Number(current.version) : 0, + }); + } + + deps.publishUiEvent({ + type: "whiteboard.changed", + agentId: id, + version: saved.version, + source: "user", + }); + return { ok: true, version: saved.version }; + } + ); + + app.post("/api/v1/agents/:id/whiteboard/snapshot", async (request, reply) => { + const params = request.params as { id?: string }; + const id = params.id ?? ""; + const agent = await deps.agentManager.getAgent(id); + if (!agent) { + return reply.code(404).send({ error: "Agent not found." }); + } + + const data = await request.file(); + if (!data || data.mimetype !== "image/png") { + return reply.code(400).send({ error: "A PNG file field is required." }); + } + + const mediaDir = resolveMediaDir(id, agent.mediaDir, deps.mediaRoot); + await mkdir(mediaDir, { recursive: true }); + const buffer = await data.toBuffer(); + await writeFile(path.join(mediaDir, WHITEBOARD_SNAPSHOT_FILENAME), buffer); + return { ok: true, sizeBytes: buffer.length }; + }); + + // Clearing the board must also clear the snapshot, or whiteboard_get + // keeps pointing agents at a rendering of the erased drawing. + app.delete( + "/api/v1/agents/:id/whiteboard/snapshot", + async (request, reply) => { + const params = request.params as { id?: string }; + const id = params.id ?? ""; + const agent = await deps.agentManager.getAgent(id); + if (!agent) { + return reply.code(404).send({ error: "Agent not found." }); + } + const mediaDir = resolveMediaDir(id, agent.mediaDir, deps.mediaRoot); + await rm(path.join(mediaDir, WHITEBOARD_SNAPSHOT_FILENAME), { + force: true, + }); + return { ok: true }; + } + ); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 86fab18a..2132e789 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -105,6 +105,7 @@ import { registerFeedbackRoutes } from "./routes/feedback.js"; import { registerJobRoutes } from "./routes/jobs.js"; import { registerTemplateRoutes } from "./routes/templates.js"; import { registerMediaRoutes } from "./routes/media.js"; +import { registerWhiteboardRoutes } from "./routes/whiteboard.js"; import { registerMcpRoutes } from "./routes/mcp.js"; import { registerPersonaReviewRoutes } from "./routes/persona-reviews.js"; import { registerPersonalityRoutes } from "./routes/personalities.js"; @@ -475,6 +476,8 @@ async function registerRoutes() { mcpRenameSession: mcpHandlers.renameSession, mcpShareMedia: mcpHandlers.shareMedia, mcpListMedia: mcpHandlers.listMedia, + mcpGetWhiteboard: mcpHandlers.getWhiteboard, + mcpUpdateWhiteboard: mcpHandlers.updateWhiteboard, mcpSubmitFeedback: mcpHandlers.submitFeedback, mcpListPersonas: mcpHandlers.listPersonas, mcpLaunchPersona: mcpHandlers.launchPersona, @@ -577,6 +580,13 @@ async function registerRoutes() { publishUiEvent: (event) => uiEventBroker.publish(event as UiEvent), }); + await registerWhiteboardRoutes(app, { + pool, + mediaRoot: config.mediaRoot, + agentManager, + publishUiEvent: (event) => uiEventBroker.publish(event as UiEvent), + }); + await registerAgentRoutes(app, { pool, appLog: app.log, diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index 7afcfc73..32ce2c5f 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -28,6 +28,7 @@ import { resolveHeadSha } from "../shared/git/worktree.js"; import { isMediaFile, isTextFile, resolveMediaDir } from "../shared/media.js"; import type { PublishUiEvent, SendAgentPrompt } from "./mcp-handler-types.js"; import { createReviewHandlers } from "./mcp-review-handlers.js"; +import { createWhiteboardHandlers } from "./mcp-whiteboard-handlers.js"; const AGENT_LATEST_EVENT_TYPES = [ "working", @@ -151,8 +152,16 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) { sendAgentPrompt, }); + const whiteboardHandlers = createWhiteboardHandlers({ + pool, + mediaRoot, + agentManager, + publishUiEvent, + }); + return { ...reviewHandlers, + ...whiteboardHandlers, async upsertEvent( agentId: string, diff --git a/apps/server/src/server/mcp-whiteboard-handlers.ts b/apps/server/src/server/mcp-whiteboard-handlers.ts new file mode 100644 index 00000000..af1ae5f6 --- /dev/null +++ b/apps/server/src/server/mcp-whiteboard-handlers.ts @@ -0,0 +1,120 @@ +import path from "node:path"; +import { stat } from "node:fs/promises"; + +import type { Pool } from "pg"; + +import type { AgentManager } from "../agents/manager.js"; +import { resolveMediaDir } from "../shared/media.js"; +import { + simplifyElements, + type WhiteboardGetResult, + type WhiteboardUpdateResult, +} from "../shared/whiteboard.js"; +import { + loadWhiteboard, + MAX_ELEMENTS, + saveWhiteboard, + WHITEBOARD_SNAPSHOT_FILENAME, +} from "../shared/whiteboard-store.js"; +import { + applyWhiteboardOps, + type WhiteboardOp, +} from "../shared/whiteboard-builder.js"; +import type { PublishUiEvent } from "./mcp-handler-types.js"; + +type CreateWhiteboardHandlersDeps = { + pool: Pool; + mediaRoot: string; + agentManager: AgentManager; + publishUiEvent: PublishUiEvent; +}; + +export function createWhiteboardHandlers(deps: CreateWhiteboardHandlersDeps) { + const { pool, mediaRoot, agentManager, publishUiEvent } = deps; + + return { + async getWhiteboard(agentId: string): Promise { + const agent = await agentManager.getAgent(agentId); + if (!agent) throw new Error("Agent not found."); + + const row = await loadWhiteboard(pool, agentId); + const snapshotFile = path.join( + resolveMediaDir(agentId, agent.mediaDir, mediaRoot), + WHITEBOARD_SNAPSHOT_FILENAME + ); + const snapshotStat = await stat(snapshotFile).catch(() => null); + const snapshotPath = snapshotStat?.isFile() ? snapshotFile : null; + return { + elements: row ? simplifyElements(row.scene.elements) : [], + version: row ? Number(row.version) : 0, + updatedAt: row ? row.updated_at.toISOString() : null, + updatedBy: row ? row.updated_by : null, + snapshotPath, + // The PNG is exported by a connected browser; agent-side edits (or a + // closed tab) leave it depicting an older scene. + snapshotStale: + snapshotPath !== null && + row !== null && + snapshotStat !== null && + row.updated_at.getTime() > snapshotStat.mtime.getTime(), + }; + }, + + async updateWhiteboard( + agentId: string, + ops: WhiteboardOp[] + ): Promise { + const agent = await agentManager.getAgent(agentId); + if (!agent) throw new Error("Agent not found."); + + // The user's editor saves concurrently; retry op application on top of + // the fresh scene when the optimistic version check loses the race. + for (let attempt = 0; attempt < 3; attempt++) { + const row = await loadWhiteboard(pool, agentId); + const baseVersion = row ? Number(row.version) : 0; + const existing = row ? row.scene.elements : []; + const result = applyWhiteboardOps(existing, ops); + + if (result.errors.length === ops.length) { + // Every op failed — nothing changed, report without saving. + return { + version: baseVersion, + created: [], + errors: result.errors, + warnings: result.warnings, + elements: simplifyElements(existing), + }; + } + if (result.elements.length > MAX_ELEMENTS) { + throw new Error(`Board is full (max ${MAX_ELEMENTS} elements).`); + } + + const saved = await saveWhiteboard( + pool, + agentId, + { elements: result.elements }, + baseVersion, + "agent" + ); + if (saved) { + publishUiEvent({ + type: "whiteboard.changed", + agentId, + version: saved.version, + source: "agent", + }); + return { + version: saved.version, + created: result.created, + errors: result.errors, + warnings: result.warnings, + elements: simplifyElements(result.elements), + }; + } + } + throw new Error( + "Whiteboard is being edited concurrently; try again in a moment." + ); + }, + }; +} diff --git a/apps/server/src/server/ui-events.ts b/apps/server/src/server/ui-events.ts index 2455b655..aafe45ad 100644 --- a/apps/server/src/server/ui-events.ts +++ b/apps/server/src/server/ui-events.ts @@ -18,6 +18,12 @@ export type UiEvent = } | { type: "agent.deleted"; agentId: string } | { type: "media.changed"; agentId: string } + | { + type: "whiteboard.changed"; + agentId: string; + version: number; + source: "user" | "agent"; + } | { type: "media.seen"; agentId: string; keys: string[] } | { type: "stream.started"; agentId: string } | { type: "stream.stopped"; agentId: string } diff --git a/apps/server/src/shared/mcp/server.ts b/apps/server/src/shared/mcp/server.ts index 750a3338..68afa979 100644 --- a/apps/server/src/shared/mcp/server.ts +++ b/apps/server/src/shared/mcp/server.ts @@ -13,6 +13,12 @@ import { registerBrainTools } from "./brain-tools.js"; import { registerCrudTools, type CrudToolCallbacks } from "./crud-tools.js"; import { registerJobTools, type JobTools } from "./job-tools.js"; import { registerMessagingTools } from "./messaging-tools.js"; +import { registerWhiteboardTools } from "./whiteboard-tools.js"; +import type { + WhiteboardGetResult, + WhiteboardUpdateResult, +} from "../whiteboard.js"; +import type { WhiteboardOp } from "../whiteboard-builder.js"; import { registerPersonaInteractionTools, type LaunchPersonaAgentType, @@ -101,6 +107,8 @@ const AGENT_TOOLS = new Set([ "get_activity_summary", "get_agent_history", "get_feedback_summary", + "whiteboard_get", + "whiteboard_update", "brain_get_object", "brain_store_object", "brain_list_objects", @@ -185,6 +193,8 @@ const PERSONA_TOOLS = new Set([ "dispatch_share", "dispatch_feedback", "get_parent_context", + // Read-only board access so review personas can see architecture sketches. + "whiteboard_get", ]); type AgentType = "agent" | "job" | "persona"; @@ -297,6 +307,11 @@ export type McpRequestContext = { createdAt: string; }> >; + getWhiteboard?: (agentId: string) => Promise; + updateWhiteboard?: ( + agentId: string, + ops: WhiteboardOp[] + ) => Promise; submitFeedback?: ( agentId: string, feedback: FeedbackInput @@ -511,6 +526,15 @@ async function createDispatchMcpServer( }); } + // ── Whiteboard tools ────────────────────────────────────────────── + if (context.agent) { + registerWhiteboardTools(server, allowed, { + agentId: context.agent.id, + getWhiteboard: context.getWhiteboard, + updateWhiteboard: context.updateWhiteboard, + }); + } + // ── Inter-agent messaging tools ─────────────────────────────────── if (context.agent) { registerMessagingTools(server, allowed, { diff --git a/apps/server/src/shared/mcp/whiteboard-tools.ts b/apps/server/src/shared/mcp/whiteboard-tools.ts new file mode 100644 index 00000000..3c12af51 --- /dev/null +++ b/apps/server/src/shared/mcp/whiteboard-tools.ts @@ -0,0 +1,232 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as z from "zod/v4"; + +import { toToolError } from "./tool-error.js"; +import type { + WhiteboardGetResult, + WhiteboardUpdateResult, +} from "../whiteboard.js"; +import type { WhiteboardOp } from "../whiteboard-builder.js"; +import { MAX_OPS } from "../whiteboard-builder.js"; + +export type WhiteboardToolsContext = { + agentId: string; + getWhiteboard?: (agentId: string) => Promise; + updateWhiteboard?: ( + agentId: string, + ops: WhiteboardOp[] + ) => Promise; +}; + +const opSchema = z.object({ + op: z + .enum(["add", "update", "delete"]) + .describe("add a new element, or update/delete an existing one by id."), + type: z + .enum(["rect", "ellipse", "diamond", "arrow", "line", "text", "frame"]) + .optional() + .describe("Element shape (required for add)."), + id: z + .string() + .regex(/^[A-Za-z0-9_-]{1,64}$/) + .optional() + .describe( + "Element id. Required for update/delete. On add, supply your own " + + "readable id (e.g. 'api-box') so later ops and arrows can reference it." + ), + x: z.number().optional().describe("Left edge (canvas px)."), + y: z.number().optional().describe("Top edge (canvas px)."), + w: z.number().positive().optional().describe("Width (default 100)."), + h: z.number().positive().optional().describe("Height (default 60)."), + label: z + .string() + .max(1000) + .optional() + .describe( + "Text centered on the shape/arrow, the content for type:text, or the " + + "title for type:frame. Keep it short." + ), + from: z + .string() + .optional() + .describe( + "Arrow start: an element id. The arrow binds to it and follows moves." + ), + to: z.string().optional().describe("Arrow end: an element id (see from)."), + color: z + .string() + .max(32) + .optional() + .describe( + "Stroke color: hex (#rrggbb) or black|gray|violet|blue|cyan|teal|" + + "green|yellow|orange|red. Defaults to the theme-adaptive default " + + "ink (black on light boards, white on dark)." + ), + fill: z + .string() + .max(32) + .optional() + .describe( + "Background fill for rect/ellipse/diamond: a named color (soft tint), " + + "hex, or 'transparent' (default). Good for grouping/emphasis." + ), + style: z + .enum(["solid", "dashed", "dotted"]) + .optional() + .describe( + "Stroke style (default solid). dashed/dotted read as optional, " + + "planned, or secondary — great for auxiliary arrows." + ), + startHead: z + .enum(["none", "arrow", "triangle", "bar", "dot", "diamond"]) + .optional() + .describe("Arrowhead at the start (arrows only; default none)."), + endHead: z + .enum(["none", "arrow", "triangle", "bar", "dot", "diamond"]) + .optional() + .describe( + "Arrowhead at the end (arrows only; default arrow). Use both heads " + + "for bidirectional relationships." + ), + via: z + .array(z.tuple([z.number(), z.number()])) + .max(10) + .optional() + .describe( + "Bend points ([x,y] canvas coords) between an arrow/line's start and " + + "end, rendered as a smooth curve. Use to route around elements " + + "instead of crossing them." + ), + elbow: z + .boolean() + .optional() + .describe( + "Route the arrow/line with right-angle bends (auto-computed; " + + "overrides via). Cleanest for box-to-box connections that aren't " + + "roughly aligned; re-routes when bound elements move." + ), +}); + +export function registerWhiteboardTools( + server: McpServer, + allowed: Set, + context: WhiteboardToolsContext +): void { + if (allowed.has("whiteboard_get") && context.getWhiteboard) { + const agentId = context.agentId; + const getWhiteboard = context.getWhiteboard; + + server.registerTool( + "whiteboard_get", + { + description: + "Get the current state of this agent's shared whiteboard — a canvas the user sketches on " + + "(architecture diagrams, flows, ideas). Returns a simplified element list (geometry, text, " + + "arrow connections via from/to element ids) plus snapshotPath: a PNG rendering of the board. " + + "Read the snapshot file to SEE the drawing — freehand sketches are hard to interpret from " + + "elements alone. Use this whenever the user refers to the whiteboard/board/drawing.", + inputSchema: {}, + }, + async () => { + try { + const board = await getWhiteboard(agentId); + const summary = { + elementCount: board.elements.length, + version: board.version, + updatedAt: board.updatedAt, + updatedBy: board.updatedBy, + snapshotPath: board.snapshotPath, + snapshotStale: board.snapshotStale, + elements: board.elements, + }; + const snapshotNote = board.snapshotPath + ? board.snapshotStale + ? `\n\nNote: ${board.snapshotPath} was rendered BEFORE the latest edits — trust the element list over the image until a browser re-exports it.` + : `\n\nTip: Read ${board.snapshotPath} to view the board visually.` + : "\n\nNo snapshot has been rendered yet (the board may be empty or never opened)."; + return { + content: [ + { + type: "text", + text: JSON.stringify(summary, null, 2) + snapshotNote, + }, + ], + structuredContent: summary, + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + if (allowed.has("whiteboard_update") && context.updateWhiteboard) { + const agentId = context.agentId; + const updateWhiteboard = context.updateWhiteboard; + + server.registerTool( + "whiteboard_update", + { + description: + "Draw on the shared whiteboard — the user sees your edits live. Use it to answer " + + "visually: sketch a fresh diagram in empty space, or annotate the user's drawing by " + + "pointing at it (arrow + short label placed NEAR their work, never on top of it). " + + "Call whiteboard_get first to learn current elements, their ids, and where free space is. " + + "Arrows with from/to bind to elements and follow them when moved; give added elements " + + "readable ids so arrows in the same call can reference them. Prefer updating or deleting " + + "your own elements by id over redrawing. Your strokes default to the standard ink; use " + + "color freely where it adds meaning. Arrows don't have to be straight: `elbow: true` gives clean " + + "right-angle routing between boxes, and `via` bend points curve an arrow around elements " + + "in its way — prefer these over long straight arrows that cross other shapes. A straight " + + "arrow only knows its endpoints, so you CANNOT see from the element list that it slices " + + "through boxes in between — the result's `warnings` reports every such crossing " + + '("passes through"); reroute those arrows or move shapes until the warnings are gone. ' + + "Your arrows render beneath shapes as a safety net, but a warned crossing still hurts " + + "readability. `style` (dashed/dotted), `startHead`/`endHead`, and shape `fill` add " + + "meaning: dashed for optional/planned paths, both heads for bidirectional links, soft " + + "fills to group; on arrow-dense boards, vary arrow colors to keep flows distinguishable. " + + "Layout rules: name a shape with its `label` (centered, " + + "auto-wrapped) — never a separate text element; labels word-wrap to the shape's width " + + "and the shape auto-grows when a label still doesn't fit, so keep labels short (2-5 " + + "words) and give ellipses/diamonds extra room (their usable text area is much smaller " + + "than a rect's); typical box w 160, h 70 with ~80px gaps; when inserting between " + + "existing elements, first move others (update x/y) to make room rather than squeezing " + + "in. The result's `warnings` list every auto-grown shape and any overlapping elements — " + + "fix them (move/resize/shorten) before telling the user the board is ready. Ops apply " + + "independently, NOT as a transaction: failed ops are skipped and listed in `errors` " + + "while the rest are saved — re-send only the failed ops (corrected), never the whole " + + "batch, or you will duplicate elements.", + inputSchema: { + ops: z + .array(opSchema) + .min(1) + .max(MAX_OPS) + .describe("Drawing operations, applied in order."), + }, + }, + async (args) => { + try { + const result = await updateWhiteboard( + agentId, + args.ops as WhiteboardOp[] + ); + const summary = { + ok: result.errors.length === 0, + version: result.version, + created: result.created, + errors: result.errors, + warnings: result.warnings, + elementCount: result.elements.length, + elements: result.elements, + }; + return { + content: [{ type: "text", text: JSON.stringify(summary, null, 2) }], + structuredContent: summary, + }; + } catch (error) { + return toToolError(error); + } + } + ); + } +} diff --git a/apps/server/src/shared/media.ts b/apps/server/src/shared/media.ts index e86816c2..3b56792b 100644 --- a/apps/server/src/shared/media.ts +++ b/apps/server/src/shared/media.ts @@ -1,3 +1,4 @@ +import os from "node:os"; import path from "node:path"; export function sanitizeUploadedFileName(name: string): string { @@ -107,7 +108,12 @@ export function resolveMediaDir( mediaDir: string | null, mediaRoot: string ): string { - return mediaDir ?? path.join(mediaRoot, agentId); + const dir = mediaDir ?? path.join(mediaRoot, agentId); + // media_dir may be stored as "~/..." (e.g. dev-created agents); Node + // treats that as a relative path and writes a literal "~" dir under cwd. + return dir.startsWith("~/") || dir === "~" + ? path.join(os.homedir(), dir.slice(1)) + : dir; } export function toMediaKey(file: { name: string; updatedAt: string }): string { diff --git a/apps/server/src/shared/whiteboard-builder.ts b/apps/server/src/shared/whiteboard-builder.ts new file mode 100644 index 00000000..c4bcc04a --- /dev/null +++ b/apps/server/src/shared/whiteboard-builder.ts @@ -0,0 +1,1220 @@ +// Expand simplified agent drawing ops into full Excalidraw elements without +// importing Excalidraw (browser-only; dies on import server-side). Field shape +// mirrors convertToExcalidrawElements output, captured from the real editor. +// +// Captured from @excalidraw/excalidraw EXCALIDRAW_CAPTURE_VERSION. Bumping the +// web pin requires re-verifying the mirrored formulas here (bound-text fitting, +// CHAR_WIDTH, getSizeFromPoints, fractional-index behavior); a unit test +// asserts the pin matches this constant so an upgrade fails loudly. +export const EXCALIDRAW_CAPTURE_VERSION = "0.18.1"; + +export type WhiteboardOpShape = + | "rect" + | "ellipse" + | "diamond" + | "arrow" + | "line" + | "text" + | "frame"; + +export type WhiteboardArrowhead = + | "none" + | "arrow" + | "triangle" + | "bar" + | "dot" + | "diamond"; + +export type WhiteboardOp = { + op: "add" | "update" | "delete"; + type?: WhiteboardOpShape; + id?: string; + x?: number; + y?: number; + w?: number; + h?: number; + label?: string; + from?: string; + to?: string; + color?: string; + fill?: string; + style?: "solid" | "dashed" | "dotted"; + startHead?: WhiteboardArrowhead; + endHead?: WhiteboardArrowhead; + via?: Array<[number, number]>; + elbow?: boolean; +}; + +export type ApplyOpsResult = { + elements: unknown[]; + created: Array<{ id: string; type: string }>; + errors: string[]; + // Non-fatal layout notices: auto-grown shapes, overlapping elements. + warnings: string[]; +}; + +export const MAX_OPS = 100; + +// Default agent ink: Excalidraw's default stroke — black on light boards, +// inverted to near-white in dark mode, so it reads cleanly on any theme. +const AGENT_STROKE = "#1e1e1e"; + +const NAMED_COLORS: Record = { + black: "#1e1e1e", + gray: "#868e96", + violet: "#6741d9", + blue: "#1971c2", + cyan: "#0c8599", + teal: "#099268", + green: "#2f9e44", + yellow: "#f08c00", + orange: "#e8590c", + red: "#e03131", +}; + +const FONT_SIZE = 20; +const LINE_HEIGHT = 1.25; +// Excalifont at 20px averages ~8.3px/char (measured from editor output). +// Over-estimate: a too-narrow box visibly clips text, a wide one is healed +// by autoResize on the first user edit. +const CHAR_WIDTH = FONT_SIZE * 0.55; + +const ID_ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"; + +function newId(): string { + let id = ""; + for (let i = 0; i < 20; i++) { + id += ID_ALPHABET[Math.floor(Math.random() * ID_ALPHABET.length)]; + } + return id; +} + +function nonce(): number { + return Math.floor(Math.random() * 2 ** 31); +} + +type Mutable = Record; + +function resolveColor(color: string | undefined): string | null { + if (color === undefined) return AGENT_STROKE; + const named = NAMED_COLORS[color.toLowerCase()]; + if (named) return named; + if (/^#[0-9a-fA-F]{6}$/.test(color)) return color; + return null; +} + +// Excalidraw's background palette: softer tints of the stroke palette, so a +// named fill reads as a wash rather than a solid block. +const NAMED_FILLS: Record = { + black: "#343a40", + gray: "#e9ecef", + violet: "#e5dbff", + blue: "#a5d8ff", + cyan: "#99e9f2", + teal: "#96f2d7", + green: "#b2f2bb", + yellow: "#ffec99", + orange: "#ffd8a8", + red: "#ffc9c9", +}; + +function resolveFill(fill: string): string | null { + if (fill.toLowerCase() === "transparent") return "transparent"; + const named = NAMED_FILLS[fill.toLowerCase()]; + if (named) return named; + if (/^#[0-9a-fA-F]{6}$/.test(fill)) return fill; + return null; +} + +const ARROWHEADS: Record = { + none: null, + arrow: "arrow", + triangle: "triangle", + bar: "bar", + dot: "dot", + diamond: "diamond", +}; + +function measureText(text: string): { width: number; height: number } { + const lines = text.split("\n"); + const longest = Math.max(...lines.map((l) => l.length), 1); + return { + width: Math.ceil(longest * CHAR_WIDTH), + height: Math.ceil(lines.length * FONT_SIZE * LINE_HEIGHT), + }; +} + +// Bound-label fitting mirrors Excalidraw's own formulas (BOUND_TEXT_PADDING, +// getBoundTextMaxWidth, computeContainerDimensionForBoundText) so the editor +// agrees with what we bake in — text must never escape its shape. +const BOUND_TEXT_PADDING = 5; +const PAD2 = BOUND_TEXT_PADDING * 2; +// Widest a shape will auto-grow for one unbreakable word before we hard-break +// the word instead (keeps a pasted URL from spawning a 1000px box). +const MAX_AUTO_TEXT_WIDTH = 440; + +const FITTED_TYPES = new Set(["rectangle", "ellipse", "diamond"]); + +function usableTextWidth(type: string, width: number): number { + if (type === "ellipse") return Math.round(width / Math.SQRT2) - PAD2; + if (type === "diamond") return width / 2 - PAD2; + if (type === "arrow" || type === "line") return Math.max(140, width * 0.7); + return width - PAD2; +} + +function usableTextHeight(type: string, height: number): number { + if (type === "ellipse") return Math.round(height / Math.SQRT2) - PAD2; + if (type === "diamond") return height / 2 - PAD2; + return height - PAD2; +} + +function containerWidthFor(type: string, textWidth: number): number { + if (type === "ellipse") return Math.round((textWidth + PAD2) * Math.SQRT2); + if (type === "diamond") return 2 * (textWidth + PAD2); + return textWidth + PAD2; +} + +function containerHeightFor(type: string, textHeight: number): number { + if (type === "ellipse") return Math.round((textHeight + PAD2) * Math.SQRT2); + if (type === "diamond") return 2 * (textHeight + PAD2); + return textHeight + PAD2; +} + +function longestWordWidth(text: string): number { + let max = 1; + for (const word of text.split(/[\s\n]+/)) max = Math.max(max, word.length); + return Math.ceil(max * CHAR_WIDTH); +} + +// Greedy word-wrap using the same CHAR_WIDTH estimate as measureText. Words +// wider than maxWidth are hard-broken so a wrapped line never exceeds it. +function wrapLabel(text: string, maxWidth: number): string { + const maxChars = Math.max(1, Math.floor(maxWidth / CHAR_WIDTH)); + const out: string[] = []; + for (const raw of text.split("\n")) { + const words = raw.split(" ").filter((w) => w.length > 0); + if (words.length === 0) { + out.push(""); + continue; + } + let line = ""; + for (let word of words) { + while (word.length > maxChars) { + if (line) { + out.push(line); + line = ""; + } + out.push(word.slice(0, maxChars)); + word = word.slice(maxChars); + } + if (!line) line = word; + else if (line.length + 1 + word.length <= maxChars) line += ` ${word}`; + else { + out.push(line); + line = word; + } + } + out.push(line); + } + return out.join("\n"); +} + +// Wrap `text` to fit `container`, growing the container when it can't (wider +// for one unbreakable word, taller for extra lines), and center `labelEl` on +// the result. Mutates both. Returns true when the container grew. +function layoutBoundText( + container: Mutable, + labelEl: Mutable, + text: string +): boolean { + const type = container.type as string; + const fitted = FITTED_TYPES.has(type); + let grew = false; + let maxW = usableTextWidth(type, container.width as number); + if (fitted) { + const wordW = Math.min(longestWordWidth(text), MAX_AUTO_TEXT_WIDTH); + if (wordW > maxW) { + maxW = wordW; + container.width = containerWidthFor(type, wordW); + grew = true; + } + } + const wrapped = wrapLabel(text, maxW); + const size = measureText(wrapped); + if ( + fitted && + size.height > usableTextHeight(type, container.height as number) + ) { + container.height = containerHeightFor(type, size.height); + grew = true; + } + const { x: cx, y: cy } = center(container); + Object.assign(labelEl, { + text: wrapped, + originalText: text, + width: size.width, + height: size.height, + x: cx - size.width / 2, + y: cy - size.height / 2, + }); + return grew; +} + +function baseElement( + type: string, + x: number, + y: number, + width: number, + height: number, + strokeColor: string +): Mutable { + return { + id: newId(), + type, + x, + y, + width, + height, + angle: 0, + strokeColor, + backgroundColor: "transparent", + fillStyle: "solid", + strokeWidth: 2, + strokeStyle: "solid", + roughness: 1, + opacity: 100, + groupIds: [], + frameId: null, + // Excalidraw assigns fractional indices to scene-less elements on load. + index: null, + roundness: null, + seed: nonce(), + version: 1, + versionNonce: nonce(), + isDeleted: false, + boundElements: null, + updated: Date.now(), + link: null, + locked: false, + customData: { author: "agent" }, + }; +} + +function bump(el: Mutable): Mutable { + return { + ...el, + version: (typeof el.version === "number" ? el.version : 0) + 1, + versionNonce: nonce(), + updated: Date.now(), + }; +} + +function boundLabel( + container: Mutable, + text: string +): { label: Mutable; grew: boolean } { + const label = baseElement( + "text", + 0, + 0, + 0, + 0, + container.strokeColor as string + ); + Object.assign(label, { + fontSize: FONT_SIZE, + fontFamily: 5, + textAlign: "center", + verticalAlign: "middle", + containerId: container.id, + autoResize: true, + lineHeight: LINE_HEIGHT, + }); + const grew = layoutBoundText(container, label, text); + container.boundElements = [ + ...((container.boundElements as unknown[]) ?? []), + { type: "text", id: label.id }, + ]; + return { label, grew }; +} + +// Where the segment from this shape's center toward (tx, ty) exits its +// bounding box — good enough for the initial render; Excalidraw re-routes +// bound arrows the moment either endpoint moves. +function edgePoint( + el: Mutable, + tx: number, + ty: number +): { x: number; y: number } { + const x = el.x as number; + const y = el.y as number; + const w = el.width as number; + const h = el.height as number; + const cx = x + w / 2; + const cy = y + h / 2; + const dx = tx - cx; + const dy = ty - cy; + if (dx === 0 && dy === 0) return { x: cx, y: cy }; + const scale = 1 / Math.max(Math.abs(dx) / (w / 2), Math.abs(dy) / (h / 2)); + return { x: cx + dx * scale, y: cy + dy * scale }; +} + +// A linear element's x,y is its FIRST point, not the bbox corner — points can +// run negative. Center must come from the points' extent or labels drift. +function center(el: Mutable): { x: number; y: number } { + const points = el.points as number[][] | undefined; + if (Array.isArray(points) && points.length > 0) { + const xs = points.map((p) => p[0]); + const ys = points.map((p) => p[1]); + return { + x: (el.x as number) + (Math.min(...xs) + Math.max(...xs)) / 2, + y: (el.y as number) + (Math.min(...ys) + Math.max(...ys)) / 2, + }; + } + return { + x: (el.x as number) + (el.width as number) / 2, + y: (el.y as number) + (el.height as number) / 2, + }; +} + +type Point = { x: number; y: number }; + +// Set a linear element's x/y/width/height/points from absolute start, bend +// (via), and end coordinates. Width/height are the points' bbox extents, +// matching Excalidraw's getSizeFromPoints. +function setLinearGeometry( + el: Mutable, + start: Point, + via: Point[], + end: Point +): void { + const rel = [ + [0, 0], + ...via.map((p) => [p.x - start.x, p.y - start.y]), + [end.x - start.x, end.y - start.y], + ]; + const xs = rel.map((p) => p[0]); + const ys = rel.map((p) => p[1]); + el.x = start.x; + el.y = start.y; + el.width = Math.max(...xs) - Math.min(...xs); + el.height = Math.max(...ys) - Math.min(...ys); + el.points = rel; +} + +// Orthogonal (right-angle) route between two endpoints, exiting bound shapes +// through the facing edge midpoint. Returns bends only — endpoints included. +function elbowRoute( + fromEl: Mutable | null, + toEl: Mutable | null, + rawStart: Point, + rawEnd: Point +): { start: Point; via: Point[]; end: Point } { + const startC = fromEl ? center(fromEl) : rawStart; + const endC = toEl ? center(toEl) : rawEnd; + const dx = endC.x - startC.x; + const dy = endC.y - startC.y; + const horizontal = Math.abs(dx) >= Math.abs(dy); + const edge = (el: Mutable, sign: number): Point => + horizontal + ? { + x: (el.x as number) + (sign > 0 ? (el.width as number) : 0), + y: (el.y as number) + (el.height as number) / 2, + } + : { + x: (el.x as number) + (el.width as number) / 2, + y: (el.y as number) + (sign > 0 ? (el.height as number) : 0), + }; + const start = fromEl ? edge(fromEl, horizontal ? dx : dy) : rawStart; + const end = toEl ? edge(toEl, horizontal ? -dx : -dy) : rawEnd; + const via: Point[] = []; + if (horizontal) { + const midX = (start.x + end.x) / 2; + if (Math.abs(start.y - end.y) > 1) { + via.push({ x: midX, y: start.y }, { x: midX, y: end.y }); + } + } else { + const midY = (start.y + end.y) / 2; + if (Math.abs(start.x - end.x) > 1) { + via.push({ x: start.x, y: midY }, { x: end.x, y: midY }); + } + } + return { start, via, end }; +} + +// Route a linear element between its endpoints: elbow (right-angle bends) or +// bend-aimed straight/curved attachment. Bound endpoints (fromEl/toEl) exit +// through the shape edge, aiming at the nearest bend or the far element. +// Owns the geometry only — callers set roundness per their own rules. +function routeLinear( + el: Mutable, + fromEl: Mutable | null, + toEl: Mutable | null, + rawStart: Point, + rawEnd: Point, + via: Point[], + elbow: boolean +): void { + if (elbow) { + const r = elbowRoute(fromEl, toEl, rawStart, rawEnd); + setLinearGeometry(el, r.start, r.via, r.end); + return; + } + const startC = fromEl ? center(fromEl) : rawStart; + const endC = toEl ? center(toEl) : rawEnd; + const startAim = via[0] ?? endC; + const endAim = via[via.length - 1] ?? startC; + const start = fromEl ? edgePoint(fromEl, startAim.x, startAim.y) : rawStart; + const end = toEl ? edgePoint(toEl, endAim.x, endAim.y) : rawEnd; + setLinearGeometry(el, start, via, end); +} + +// Re-center an element's bound label on its current geometry. +function recenteredLabel( + container: Mutable, + live: (id: string | undefined) => Mutable | null +): Mutable | null { + const ref = ( + (container.boundElements as Array<{ id?: string; type?: string }>) ?? [] + ).find((b) => b.type === "text"); + const labelEl = ref?.id ? live(ref.id) : null; + if (!labelEl) return null; + const c = center(container); + return { + ...bump(labelEl), + x: c.x - (labelEl.width as number) / 2, + y: c.y - (labelEl.height as number) / 2, + }; +} + +// Recompute an arrow's endpoints from its bindings. Excalidraw only re-routes +// bound arrows during in-editor drags; server-side moves must do it here or +// arrows stay stranded at their old geometry. +function rerouteArrow( + arrow: Mutable, + resolve: (id: string | undefined) => Mutable | null +): Mutable | null { + const fromEl = resolve( + (arrow.startBinding as { elementId?: string } | null)?.elementId + ); + const toEl = resolve( + (arrow.endBinding as { elementId?: string } | null)?.elementId + ); + if (!fromEl && !toEl) return null; + const points = arrow.points as number[][]; + const ax = arrow.x as number; + const ay = arrow.y as number; + const oldEnd = { + x: ax + (points?.[points.length - 1]?.[0] ?? 0), + y: ay + (points?.[points.length - 1]?.[1] ?? 0), + }; + const oldStart = { x: ax, y: ay }; + const next = bump(arrow); + const elbow = + (arrow.customData as { elbow?: boolean } | undefined)?.elbow === true; + // Non-elbow: keep hand-set bends where they are; only the bound endpoints + // move, each aiming at its nearest bend (or the far element when straight). + const bends = elbow + ? [] + : (points ?? []).slice(1, -1).map((p) => ({ x: ax + p[0], y: ay + p[1] })); + routeLinear(next, fromEl, toEl, oldStart, oldEnd, bends, elbow); + return next; +} + +export function applyWhiteboardOps( + existing: unknown[], + ops: WhiteboardOp[] +): ApplyOpsResult { + const errors: string[] = []; + const warnings: string[] = []; + const created: Array<{ id: string; type: string }> = []; + // Elements this call added, moved, resized, or grew — overlap-checked below. + const touched = new Set(); + + const grewNote = (at: string, el: Mutable): void => { + warnings.push( + `${at}: "${el.id}" grew to ${el.width}x${el.height} to fit its label; ` + + `move neighbors if it now crowds them.` + ); + }; + + // Work on a map so boundElements/binding rewrites replace in place. + const order: string[] = []; + const byId = new Map(); + for (const raw of existing) { + if (typeof raw !== "object" || raw === null) continue; + const el = { ...(raw as Mutable) }; + const id = typeof el.id === "string" ? el.id : newId(); + order.push(id); + byId.set(id, el); + } + const appended: string[] = []; + + const put = (el: Mutable): void => { + const id = el.id as string; + if (!byId.has(id)) appended.push(id); + byId.set(id, el); + }; + + const live = (id: string | undefined): Mutable | null => { + if (!id) return null; + const el = byId.get(id); + return el && el.isDeleted !== true ? el : null; + }; + + // Validate cross-cutting style fields; returns null (and records an error) + // on bad input. Enum fields (style, heads) are schema-checked upstream. + const checkStyle = ( + at: string, + op: WhiteboardOp, + type: string + ): { fill?: string } | null => { + const linear = type === "arrow" || type === "line"; + let fill: string | undefined; + if (op.fill !== undefined) { + if (!FITTED_TYPES.has(type === "rect" ? "rectangle" : type)) { + errors.push(`${at}: fill only applies to rect/ellipse/diamond.`); + return null; + } + const resolved = resolveFill(op.fill); + if (!resolved) { + errors.push(`${at}: unknown fill "${op.fill}".`); + return null; + } + fill = resolved; + } + if ( + (op.startHead !== undefined || op.endHead !== undefined) && + type !== "arrow" + ) { + errors.push(`${at}: startHead/endHead only apply to arrows.`); + return null; + } + if ((op.via !== undefined || op.elbow !== undefined) && !linear) { + errors.push(`${at}: via/elbow only apply to arrows and lines.`); + return null; + } + if (op.via !== undefined) { + if ( + op.via.length > 10 || + op.via.some( + (p) => + !Array.isArray(p) || + p.length !== 2 || + p.some((n) => typeof n !== "number" || !Number.isFinite(n)) + ) + ) { + errors.push(`${at}: via must be up to 10 [x,y] number pairs.`); + return null; + } + } + return { fill }; + }; + + for (const [i, op] of ops.entries()) { + const at = `ops[${i}]`; + if (op.op === "add") { + const stroke = resolveColor(op.color); + if (!stroke) { + errors.push(`${at}: unknown color "${op.color}".`); + continue; + } + const styled = checkStyle(at, op, op.type ?? ""); + if (!styled) continue; + if (op.id !== undefined) { + if (!/^[A-Za-z0-9_-]{1,64}$/.test(op.id)) { + errors.push(`${at}: id must match [A-Za-z0-9_-]{1,64}.`); + continue; + } + if (byId.has(op.id)) { + errors.push(`${at}: id "${op.id}" already exists on the board.`); + continue; + } + } + const x = op.x ?? 0; + const y = op.y ?? 0; + const w = op.w ?? 100; + const h = op.h ?? 60; + + switch (op.type) { + case "rect": + case "ellipse": + case "diamond": { + const el = baseElement( + op.type === "rect" ? "rectangle" : op.type, + x, + y, + w, + h, + stroke + ); + if (op.id) el.id = op.id; + if (op.type === "rect") el.roundness = { type: 3 }; + if (styled.fill !== undefined) el.backgroundColor = styled.fill; + if (op.style) el.strokeStyle = op.style; + put(el); + touched.add(el.id as string); + created.push({ id: el.id as string, type: op.type }); + if (op.label) { + const fit = boundLabel(el, op.label); + put(fit.label); + if (fit.grew) grewNote(at, el); + } + break; + } + case "text": { + if (!op.label) { + errors.push(`${at}: text requires a label.`); + continue; + } + const size = measureText(op.label); + const el = baseElement("text", x, y, size.width, size.height, stroke); + if (op.id) el.id = op.id; + Object.assign(el, { + text: op.label, + fontSize: FONT_SIZE, + fontFamily: 5, + textAlign: "left", + verticalAlign: "top", + containerId: null, + originalText: op.label, + autoResize: true, + lineHeight: LINE_HEIGHT, + }); + put(el); + touched.add(el.id as string); + created.push({ id: el.id as string, type: "text" }); + break; + } + case "frame": { + const el = baseElement("frame", x, y, w, h, "#1e1e1e"); + if (op.id) el.id = op.id; + el.name = op.label ?? null; + put(el); + created.push({ id: el.id as string, type: "frame" }); + break; + } + case "arrow": + case "line": { + const el = baseElement(op.type, x, y, w, h, stroke); + if (op.id) el.id = op.id; + Object.assign(el, { + points: [ + [0, 0], + [op.w ?? 100, op.h ?? 0], + ], + lastCommittedPoint: null, + startBinding: null, + endBinding: null, + startArrowhead: + op.startHead !== undefined ? ARROWHEADS[op.startHead] : null, + endArrowhead: + op.endHead !== undefined + ? ARROWHEADS[op.endHead] + : op.type === "arrow" + ? "arrow" + : null, + }); + if (op.type === "arrow") el.elbowed = false; + if (op.style) el.strokeStyle = op.style; + if (op.elbow) { + el.customData = { ...(el.customData as Mutable), elbow: true }; + } + + const fromEl = live(op.from); + const toEl = live(op.to); + if (op.from && !fromEl) { + errors.push(`${at}: from element "${op.from}" not found.`); + continue; + } + if (op.to && !toEl) { + errors.push(`${at}: to element "${op.to}" not found.`); + continue; + } + const rawStart = { x, y }; + const rawEnd = { x: x + (op.w ?? 100), y: y + (op.h ?? 0) }; + if (op.elbow) { + routeLinear(el, fromEl, toEl, rawStart, rawEnd, [], true); + } else if (op.via?.length || fromEl || toEl) { + const via = (op.via ?? []).map(([vx, vy]) => ({ x: vx, y: vy })); + routeLinear(el, fromEl, toEl, rawStart, rawEnd, via, false); + // Freeform bends read best as smooth curves; elbows stay crisp. + if (via.length > 0) el.roundness = { type: 2 }; + } + if (fromEl || toEl) { + const arrowRef = { id: el.id, type: "arrow" }; + if (fromEl) { + el.startBinding = { elementId: fromEl.id, focus: 0, gap: 4 }; + put({ + ...bump(fromEl), + boundElements: [ + ...((fromEl.boundElements as unknown[]) ?? []), + arrowRef, + ], + }); + } + if (toEl) { + el.endBinding = { elementId: toEl.id, focus: 0, gap: 4 }; + put({ + ...bump(toEl), + boundElements: [ + ...((toEl.boundElements as unknown[]) ?? []), + arrowRef, + ], + }); + } + } + put(el); + touched.add(el.id as string); + created.push({ id: el.id as string, type: op.type }); + if (op.label) { + const label = boundLabel(el, op.label).label; + put(label); + touched.add(label.id as string); + } + break; + } + default: + errors.push(`${at}: unknown type "${op.type}".`); + } + continue; + } + + const target = live(op.id); + if (!target) { + errors.push(`${at}: element "${op.id}" not found.`); + continue; + } + + if (op.op === "update") { + const styled = checkStyle(at, op, target.type as string); + if (!styled) continue; + const updated = bump(target); + if (op.x !== undefined) updated.x = op.x; + if (op.y !== undefined) updated.y = op.y; + if (op.w !== undefined) updated.width = op.w; + if (op.h !== undefined) updated.height = op.h; + if (op.color !== undefined) { + const stroke = resolveColor(op.color); + if (!stroke) { + errors.push(`${at}: unknown color "${op.color}".`); + continue; + } + updated.strokeColor = stroke; + } + if (styled.fill !== undefined) updated.backgroundColor = styled.fill; + if (op.style) updated.strokeStyle = op.style; + if (op.startHead !== undefined) { + updated.startArrowhead = ARROWHEADS[op.startHead]; + } + if (op.endHead !== undefined) { + updated.endArrowhead = ARROWHEADS[op.endHead]; + } + const movedXY = op.x !== undefined || op.y !== undefined; + const resized = op.w !== undefined || op.h !== undefined; + let rerouted = false; + let grew = false; + + // Re-shape an existing arrow/line: new bends (via) or elbow routing, + // anchored to its bindings when present. + if (op.via !== undefined || op.elbow !== undefined) { + const pts = updated.points as number[][] | undefined; + const ux = updated.x as number; + const uy = updated.y as number; + const rawStart = { x: ux, y: uy }; + const rawEnd = { + x: ux + (pts?.[pts.length - 1]?.[0] ?? 0), + y: uy + (pts?.[pts.length - 1]?.[1] ?? 0), + }; + const fromEl = live( + (updated.startBinding as { elementId?: string } | null)?.elementId + ); + const toEl = live( + (updated.endBinding as { elementId?: string } | null)?.elementId + ); + updated.customData = { + ...(updated.customData as Mutable), + elbow: op.elbow === true, + }; + const via = op.elbow + ? [] + : (op.via ?? []).map(([vx, vy]) => ({ x: vx, y: vy })); + routeLinear(updated, fromEl, toEl, rawStart, rawEnd, via, !!op.elbow); + updated.roundness = via.length > 0 ? { type: 2 } : null; + rerouted = true; + touched.add(updated.id as string); + } + + if (op.label !== undefined && updated.type === "text") { + const size = measureText(op.label); + Object.assign(updated, { + text: op.label, + originalText: op.label, + width: size.width, + height: size.height, + }); + } else if (op.label !== undefined && updated.type === "frame") { + updated.name = op.label; + } else if (updated.type !== "text" && updated.type !== "frame") { + const boundText = ( + (updated.boundElements as Array<{ + id?: string; + type?: string; + }>) ?? [] + ).find((b) => b.type === "text"); + const labelEl = boundText?.id ? live(boundText.id) : null; + if (op.label !== undefined && !labelEl) { + const fit = boundLabel(updated, op.label); + put(fit.label); + touched.add(fit.label.id as string); + grew = fit.grew; + } else if (labelEl && (op.label !== undefined || resized)) { + // Label or size changed: re-wrap to the new geometry. + const next = bump(labelEl); + const text = + op.label ?? + (labelEl.originalText as string | undefined) ?? + (labelEl.text as string); + grew = layoutBoundText(updated, next, text); + put(next); + touched.add(next.id as string); + } else if (labelEl && (movedXY || rerouted)) { + // Pure move: re-center without re-wrapping user-formatted text. + const moved = recenteredLabel(updated, live); + if (moved) put(moved); + } + } + if (grew) grewNote(at, updated); + + put(updated); + const geometryChanged = + movedXY || + resized || + grew || + (op.label !== undefined && updated.type === "text"); + if (geometryChanged) { + touched.add(updated.id as string); + // Attached arrows must follow the moved/grown element. + for (const ref of (updated.boundElements as Array<{ + id?: string; + type?: string; + }>) ?? []) { + if (ref.type !== "arrow" || !ref.id) continue; + const arrow = live(ref.id); + if (!arrow) continue; + const rerouted = rerouteArrow(arrow, live); + if (rerouted) { + put(rerouted); + touched.add(rerouted.id as string); + // The arrow's own label must follow it to the new midpoint — + // and its landing spot needs the same overlap check as any move. + const label = recenteredLabel(rerouted, live); + if (label) { + put(label); + touched.add(label.id as string); + } + } + } + } + continue; + } + + if (op.op === "delete") { + put({ ...bump(target), isDeleted: true }); + // Bound labels die with their container. + for (const ref of (target.boundElements as Array<{ + id?: string; + type?: string; + }>) ?? []) { + if (ref.type === "text" && ref.id) { + const labelEl = live(ref.id); + if (labelEl) put({ ...bump(labelEl), isDeleted: true }); + } + // Arrows bound to a deleted shape survive; drop the dangling binding. + if (ref.type === "arrow" && ref.id) { + const arrow = live(ref.id); + if (!arrow) continue; + const next = bump(arrow); + const sb = next.startBinding as { elementId?: string } | null; + const eb = next.endBinding as { elementId?: string } | null; + if (sb?.elementId === target.id) next.startBinding = null; + if (eb?.elementId === target.id) next.endBinding = null; + put(next); + } + } + // Deleting an arrow (or contained label) leaves back-references on the + // elements it was attached to; scrub them. + const detachFrom = new Set(); + const sb = target.startBinding as { elementId?: string } | null; + const eb = target.endBinding as { elementId?: string } | null; + if (typeof sb?.elementId === "string") detachFrom.add(sb.elementId); + if (typeof eb?.elementId === "string") detachFrom.add(eb.elementId); + if (typeof target.containerId === "string") { + detachFrom.add(target.containerId); + } + for (const hostId of detachFrom) { + const host = live(hostId); + if (!host) continue; + put({ + ...bump(host), + boundElements: ( + (host.boundElements as Array<{ id?: string }>) ?? [] + ).filter((b) => b.id !== target.id), + }); + } + // Freed frame children stay on the board. + if (target.type === "frame") { + for (const [id, el] of byId) { + if (el.frameId === target.id && el.isDeleted !== true) { + put({ ...bump(byId.get(id) as Mutable), frameId: null }); + } + } + } + continue; + } + + errors.push(`${at}: unknown op "${(op as { op?: string }).op}".`); + } + + warnings.push(...overlapWarnings(byId, touched)); + warnings.push(...crossingWarnings(byId, touched)); + + return { + elements: sendConnectorsToBack([...order, ...appended], byId), + created, + errors, + warnings, + }; +} + +// Agent-drawn arrows/lines render BENEATH shapes: a connector that still +// crosses something reads as passing behind the box, not through its text. +// User elements keep their relative order; arrow labels stay on top. +function sendConnectorsToBack( + ids: string[], + byId: Map +): unknown[] { + const isConnector = (id: string): boolean => { + const el = byId.get(id); + return ( + (el?.type === "arrow" || el?.type === "line") && + (el?.customData as { author?: string } | undefined)?.author === "agent" + ); + }; + const connectors = ids.filter(isConnector); + if (connectors.length === 0) return ids.map((id) => byId.get(id)); + const rest = ids.filter((id) => !isConnector(id)); + // Excalidraw orders by fractional index when present, which would undo the + // array move — null the index of any connector we displaced so the editor + // re-assigns one matching its new position. + const firstShape = ids.findIndex((id) => !isConnector(id)); + for (const id of connectors) { + const el = byId.get(id) as Mutable; + if (ids.indexOf(id) > firstShape && el.index !== null) { + byId.set(id, { ...el, index: null }); + } + } + return [...connectors, ...rest].map((id) => byId.get(id)); +} + +const MAX_OVERLAP_WARNINGS = 6; +// Ignore near-touches; only clearly overlapping shapes are worth a warning. +const OVERLAP_SLACK = 4; + +function overlapCandidate( + el: Mutable | undefined, + byId: Map +): el is Mutable { + if (!el || el.isDeleted === true) return false; + if (FITTED_TYPES.has(el.type as string)) return true; + if (el.type !== "text") return false; + // Standalone text sprawled over a shape is the classic mess. Shape labels + // are positioned by us inside their shape and skipped — but arrow labels + // sit at the arrow midpoint, which can land on unrelated elements. + if (!el.containerId) return true; + const container = byId.get(el.containerId as string); + return container?.type === "arrow" || container?.type === "line"; +} + +// Is `label` bound to an arrow that starts or ends at `shape`? Labels on a +// short arrow legitimately sit close to its own endpoints. +function boundToNeighbor( + label: Mutable, + shape: Mutable, + byId: Map +): boolean { + const container = byId.get(label.containerId as string); + if (!container) return false; + const sb = container.startBinding as { elementId?: string } | null; + const eb = container.endBinding as { elementId?: string } | null; + return sb?.elementId === shape.id || eb?.elementId === shape.id; +} + +// Overlaps involving an arrow's label blame the arrow, not the random text id. +function describeForOverlap(el: Mutable): string { + return el.type === "text" && el.containerId + ? `the label of "${el.containerId}"` + : `"${el.id}"`; +} + +// Bounding-box overlap worth warning about. Shape-in-shape containment is +// often intentional (a badge inside a box) and exempt — but text inside an +// unrelated shape is exactly the clutter we're hunting, so it always counts. +function overlapsBadly(a: Mutable, b: Mutable): boolean { + const ax = a.x as number; + const ay = a.y as number; + const aw = a.width as number; + const ah = a.height as number; + const bx = b.x as number; + const by = b.y as number; + const bw = b.width as number; + const bh = b.height as number; + const ix = Math.min(ax + aw, bx + bw) - Math.max(ax, bx); + const iy = Math.min(ay + ah, by + bh) - Math.max(ay, by); + if (ix <= OVERLAP_SLACK || iy <= OVERLAP_SLACK) return false; + if (a.type === "text" || b.type === "text") return true; + const aInB = ax >= bx && ay >= by && ax + aw <= bx + bw && ay + ah <= by + bh; + const bInA = bx >= ax && by >= ay && bx + bw <= ax + aw && by + bh <= ay + ah; + return !aInB && !bInA; +} + +// Warn about elements this call placed (or grew) on top of other elements. +function overlapWarnings( + byId: Map, + touched: Set +): string[] { + const warnings: string[] = []; + const reported = new Set(); + let extra = 0; + for (const id of touched) { + const el = byId.get(id); + if (!overlapCandidate(el, byId)) continue; + for (const [otherId, other] of byId) { + if (otherId === id) continue; + if (touched.has(otherId) && reported.has(`${otherId}|${id}`)) continue; + if (!overlapCandidate(other, byId)) continue; + // An arrow's label may touch the arrow's own endpoints; skip that pair. + if (el.containerId && boundToNeighbor(el, other, byId)) continue; + if (other.containerId && boundToNeighbor(other, el, byId)) continue; + if (!overlapsBadly(el, other)) continue; + reported.add(`${id}|${otherId}`); + if (warnings.length >= MAX_OVERLAP_WARNINGS) { + extra++; + continue; + } + warnings.push( + `${describeForOverlap(el)} overlaps ${describeForOverlap(other)} — ` + + `move or resize one (update x/y) to keep the board readable.` + ); + } + } + if (extra > 0) warnings.push(`…and ${extra} more overlapping pair(s).`); + return warnings; +} + +const MAX_CROSSING_WARNINGS = 6; + +// Does the segment (x1,y1)→(x2,y2) pass through the box? Liang–Barsky clip; +// a graze along the edge (chord shorter than ~2px) doesn't count. +function segmentHitsBox( + x1: number, + y1: number, + x2: number, + y2: number, + bx: number, + by: number, + bw: number, + bh: number +): boolean { + const dx = x2 - x1; + const dy = y2 - y1; + const p = [-dx, dx, -dy, dy]; + const q = [x1 - bx, bx + bw - x1, y1 - by, by + bh - y1]; + let t0 = 0; + let t1 = 1; + for (let i = 0; i < 4; i++) { + if (p[i] === 0) { + if (q[i] < 0) return false; + continue; + } + const r = q[i] / p[i]; + if (p[i] < 0) { + if (r > t1) return false; + if (r > t0) t0 = r; + } else { + if (r < t0) return false; + if (r < t1) t1 = r; + } + } + return (t1 - t0) * Math.hypot(dx, dy) > 2; +} + +// Shapes an arrow should not be drawn through: closed shapes and free text. +// Ellipses/diamonds use their bbox — close enough for a routing nudge. +function crossingTarget(el: Mutable | undefined): el is Mutable { + if (!el || el.isDeleted === true) return false; + if (FITTED_TYPES.has(el.type as string)) return true; + return el.type === "text" && !el.containerId; +} + +// Warn when an arrow/line's path cuts through a shape it isn't bound to. +// Arrows only know their endpoints; without this the agent cannot tell that +// a "clean" straight arrow slices through three boxes on its way. +function crossingWarnings( + byId: Map, + touched: Set +): string[] { + const warnings: string[] = []; + let extra = 0; + for (const [arrowId, arrow] of byId) { + if (arrow.type !== "arrow" && arrow.type !== "line") continue; + if (arrow.isDeleted === true) continue; + const points = arrow.points as number[][] | undefined; + if (!Array.isArray(points) || points.length < 2) continue; + const ax = arrow.x as number; + const ay = arrow.y as number; + const boundIds = new Set( + [ + (arrow.startBinding as { elementId?: string } | null)?.elementId, + (arrow.endBinding as { elementId?: string } | null)?.elementId, + ].filter((id): id is string => typeof id === "string") + ); + for (const [shapeId, shape] of byId) { + if (shapeId === arrowId || boundIds.has(shapeId)) continue; + if (!touched.has(arrowId) && !touched.has(shapeId)) continue; + if (!crossingTarget(shape)) continue; + // Inset by the slack so brushing a border doesn't count. + const bx = (shape.x as number) + OVERLAP_SLACK; + const by = (shape.y as number) + OVERLAP_SLACK; + const bw = (shape.width as number) - OVERLAP_SLACK * 2; + const bh = (shape.height as number) - OVERLAP_SLACK * 2; + if (bw <= 0 || bh <= 0) continue; + const hit = points.some((p, i) => { + if (i === 0) return false; + const prev = points[i - 1]; + return segmentHitsBox( + ax + prev[0], + ay + prev[1], + ax + p[0], + ay + p[1], + bx, + by, + bw, + bh + ); + }); + if (!hit) continue; + if (warnings.length >= MAX_CROSSING_WARNINGS) { + extra++; + continue; + } + warnings.push( + `"${arrowId}" passes through "${shapeId}" — reroute it around ` + + `(elbow:true or via bend points) or move one of them.` + ); + } + } + if (extra > 0) warnings.push(`…and ${extra} more arrow crossing(s).`); + return warnings; +} diff --git a/apps/server/src/shared/whiteboard-store.ts b/apps/server/src/shared/whiteboard-store.ts new file mode 100644 index 00000000..3d3cb05e --- /dev/null +++ b/apps/server/src/shared/whiteboard-store.ts @@ -0,0 +1,66 @@ +// Whiteboard persistence: one row per agent, optimistic-locked on `version`. +// Shared by the HTTP routes (routes/whiteboard.ts) and the MCP handlers +// (server/mcp-whiteboard-handlers.ts). + +import type { Pool } from "pg"; + +// Written into the agent's media dir WITHOUT a `media` DB row: the media +// list is DB-backed, so the snapshot stays out of the unseen-badge flow +// while remaining readable by the agent and servable via the media route. +export const WHITEBOARD_SNAPSHOT_FILENAME = "whiteboard.png"; + +export const MAX_ELEMENTS = 20_000; + +export const EMPTY_SCENE = { elements: [] as unknown[] }; + +export type WhiteboardRow = { + scene: { elements: unknown[] }; + version: string; + updated_by: string; + updated_at: Date; +}; + +export async function loadWhiteboard( + pool: Pool, + agentId: string +): Promise { + const result = await pool.query( + "SELECT scene, version, updated_by, updated_at FROM whiteboards WHERE agent_id = $1", + [agentId] + ); + return result.rows[0] ?? null; +} + +export function isValidScene(scene: unknown): scene is { elements: unknown[] } { + return ( + typeof scene === "object" && + scene !== null && + Array.isArray((scene as { elements?: unknown }).elements) && + (scene as { elements: unknown[] }).elements.length <= MAX_ELEMENTS + ); +} + +export async function saveWhiteboard( + pool: Pool, + agentId: string, + scene: { elements: unknown[] }, + baseVersion: number, + updatedBy: "user" | "agent" +): Promise<{ version: number } | null> { + const result = await pool.query<{ version: string }>( + `INSERT INTO whiteboards (agent_id, scene, version, updated_by) + VALUES ($1, $2::jsonb, 1, $3) + ON CONFLICT (agent_id) DO UPDATE + SET scene = EXCLUDED.scene, + version = whiteboards.version + 1, + updated_by = EXCLUDED.updated_by, + updated_at = NOW() + WHERE whiteboards.version = $4 + RETURNING version`, + [agentId, JSON.stringify(scene), updatedBy, baseVersion] + ); + if (result.rows.length === 0) { + return null; + } + return { version: Number(result.rows[0].version) }; +} diff --git a/apps/server/src/shared/whiteboard.ts b/apps/server/src/shared/whiteboard.ts new file mode 100644 index 00000000..fd8bc904 --- /dev/null +++ b/apps/server/src/shared/whiteboard.ts @@ -0,0 +1,92 @@ +// Reduce raw Excalidraw elements to a compact, agent-readable summary: +// geometry, text, connections. Style noise (seeds, versions, strokes, +// roundness) is stripped — agents get the PNG snapshot for visual detail. + +type RawElement = Record; + +export type SimplifiedElement = { + id: string; + type: string; + x: number; + y: number; + width: number; + height: number; + angle?: number; + text?: string; + containerId?: string; + from?: string; + to?: string; + frameId?: string; + strokeColor?: string; + backgroundColor?: string; +}; + +// Result shapes shared by the MCP handlers (server/mcp-whiteboard-handlers.ts) +// and the tool/context layers (shared/mcp) — one definition, three consumers. +export type WhiteboardGetResult = { + elements: SimplifiedElement[]; + version: number; + updatedAt: string | null; + updatedBy: string | null; + snapshotPath: string | null; + snapshotStale: boolean; +}; + +export type WhiteboardUpdateResult = { + version: number; + created: Array<{ id: string; type: string }>; + errors: string[]; + warnings: string[]; + elements: SimplifiedElement[]; +}; + +function num(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.round(value) + : 0; +} + +function str(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined; +} + +export function simplifyElements(elements: unknown[]): SimplifiedElement[] { + const out: SimplifiedElement[] = []; + for (const raw of elements) { + if (typeof raw !== "object" || raw === null) continue; + const el = raw as RawElement; + if (el.isDeleted === true) continue; + + const simplified: SimplifiedElement = { + id: str(el.id) ?? "", + type: str(el.type) ?? "unknown", + x: num(el.x), + y: num(el.y), + width: num(el.width), + height: num(el.height), + }; + if (typeof el.angle === "number" && Math.abs(el.angle) > 0.01) { + simplified.angle = Number((el.angle as number).toFixed(2)); + } + const text = str(el.text) ?? str(el.originalText); + if (text) simplified.text = text; + const containerId = str((el as { containerId?: unknown }).containerId); + if (containerId) simplified.containerId = containerId; + const startBinding = el.startBinding as { elementId?: unknown } | null; + const endBinding = el.endBinding as { elementId?: unknown } | null; + const from = str(startBinding?.elementId); + const to = str(endBinding?.elementId); + if (from) simplified.from = from; + if (to) simplified.to = to; + const frameId = str(el.frameId); + if (frameId) simplified.frameId = frameId; + const strokeColor = str(el.strokeColor); + if (strokeColor) simplified.strokeColor = strokeColor; + const backgroundColor = str(el.backgroundColor); + if (backgroundColor && backgroundColor !== "transparent") { + simplified.backgroundColor = backgroundColor; + } + out.push(simplified); + } + return out; +} diff --git a/apps/server/test/whiteboard-builder.test.ts b/apps/server/test/whiteboard-builder.test.ts new file mode 100644 index 00000000..a0c42bb3 --- /dev/null +++ b/apps/server/test/whiteboard-builder.test.ts @@ -0,0 +1,1078 @@ +import { describe, expect, it } from "vitest"; + +import { + applyWhiteboardOps, + type WhiteboardOp, +} from "../src/shared/whiteboard-builder.js"; + +type El = Record; + +function apply(existing: unknown[], ops: WhiteboardOp[]) { + return applyWhiteboardOps(existing, ops); +} + +function byId(elements: unknown[], id: string): El { + const el = (elements as El[]).find((e) => e.id === id); + expect(el, `element ${id}`).toBeDefined(); + return el as El; +} + +// Field shape mirrors convertToExcalidrawElements output (captured from the +// real editor during Spike A); these tests pin the contract. +const BASE_FIELDS = [ + "id", + "type", + "x", + "y", + "width", + "height", + "angle", + "strokeColor", + "backgroundColor", + "fillStyle", + "strokeWidth", + "strokeStyle", + "roughness", + "opacity", + "groupIds", + "frameId", + "index", + "roundness", + "seed", + "version", + "versionNonce", + "isDeleted", + "boundElements", + "updated", + "link", + "locked", +]; + +describe("applyWhiteboardOps: add", () => { + it("creates a rectangle with every base field and the agent author stamp", () => { + const { elements, created, errors } = apply( + [], + [{ op: "add", type: "rect", id: "r1", x: 10, y: 20, w: 160, h: 70 }] + ); + expect(errors).toEqual([]); + expect(created).toEqual([{ id: "r1", type: "rect" }]); + const rect = byId(elements, "r1"); + for (const field of BASE_FIELDS) { + expect(rect, field).toHaveProperty(field); + } + expect(rect.type).toBe("rectangle"); + expect(rect.customData).toEqual({ author: "agent" }); + expect(rect.strokeColor).toBe("#1e1e1e"); // default ink + expect(rect.index).toBeNull(); // editor assigns fractional indices + expect(rect.isDeleted).toBe(false); + }); + + it("creates a bound, centered label text element for labelled shapes", () => { + const { elements } = apply( + [], + [ + { + op: "add", + type: "rect", + id: "r1", + x: 0, + y: 0, + w: 200, + h: 100, + label: "api", + }, + ] + ); + expect(elements).toHaveLength(2); + const rect = byId(elements, "r1"); + const labelRef = rect.boundElements?.[0]; + expect(labelRef?.type).toBe("text"); + const label = byId(elements, labelRef.id); + expect(label.containerId).toBe("r1"); + expect(label.originalText).toBe("api"); + expect(label.fontFamily).toBe(5); + expect(label.textAlign).toBe("center"); + expect(label.verticalAlign).toBe("middle"); + // Centered on the container. + expect(label.x + label.width / 2).toBeCloseTo(100, 0); + expect(label.y + label.height / 2).toBeCloseTo(50, 0); + }); + + it("binds arrows to from/to elements and back-references them", () => { + const { elements, errors } = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 0, w: 100, h: 60 }, + { op: "add", type: "rect", id: "b", x: 300, y: 0, w: 100, h: 60 }, + { op: "add", type: "arrow", id: "ab", from: "a", to: "b" }, + ] + ); + expect(errors).toEqual([]); + const arrow = byId(elements, "ab"); + expect(arrow.startBinding).toMatchObject({ elementId: "a" }); + expect(arrow.endBinding).toMatchObject({ elementId: "b" }); + expect(arrow.elbowed).toBe(false); + expect(arrow.endArrowhead).toBe("arrow"); + expect(arrow.points).toHaveLength(2); + // The arrow spans the gap between the shapes' facing edges. + expect(arrow.x).toBeGreaterThanOrEqual(100); + expect(arrow.x + arrow.points[1][0]).toBeLessThanOrEqual(300); + for (const shapeId of ["a", "b"]) { + const refs = byId(elements, shapeId).boundElements as El[]; + expect(refs.some((r) => r.id === "ab" && r.type === "arrow")).toBe(true); + } + }); + + it("supports named colors, hex colors, and rejects junk", () => { + const { elements, errors } = apply( + [], + [ + { op: "add", type: "rect", id: "g", color: "green" }, + { op: "add", type: "rect", id: "h", color: "#123abc" }, + { op: "add", type: "rect", id: "bad", color: "chartreuse-ish" }, + ] + ); + expect(byId(elements, "g").strokeColor).toBe("#2f9e44"); + expect(byId(elements, "h").strokeColor).toBe("#123abc"); + expect(errors).toHaveLength(1); + expect((elements as El[]).some((e) => e.id === "bad")).toBe(false); + }); + + it("rejects duplicate and malformed ids", () => { + const { errors } = apply( + [], + [ + { op: "add", type: "rect", id: "dup" }, + { op: "add", type: "rect", id: "dup" }, + { op: "add", type: "rect", id: "bad id!" }, + ] + ); + expect(errors).toHaveLength(2); + }); + + it("errors on arrows pointing at unknown elements", () => { + const { errors, created } = apply( + [], + [{ op: "add", type: "arrow", id: "a1", from: "ghost", to: "spirit" }] + ); + expect(errors).toHaveLength(1); + expect(created).toEqual([]); + }); + + it("creates standalone text and frames", () => { + const { elements, errors } = apply( + [], + [ + { op: "add", type: "text", id: "t1", x: 5, y: 5, label: "hi\nthere" }, + { + op: "add", + type: "frame", + id: "f1", + x: 0, + y: 0, + w: 300, + h: 200, + label: "proposal", + }, + { op: "add", type: "text", id: "t2", x: 5, y: 5 }, + ] + ); + const text = byId(elements, "t1"); + expect(text.text).toBe("hi\nthere"); + expect(text.height).toBeGreaterThan(25); // two lines + expect(byId(elements, "f1").name).toBe("proposal"); + expect(errors).toHaveLength(1); // text without label + }); +}); + +describe("applyWhiteboardOps: update", () => { + it("moves geometry, bumps version, and re-centers the bound label", () => { + const first = apply( + [], + [ + { + op: "add", + type: "rect", + id: "r1", + x: 0, + y: 0, + w: 100, + h: 60, + label: "hey", + }, + ] + ); + const before = byId(first.elements, "r1"); + const { elements, errors } = apply(first.elements, [ + { op: "update", id: "r1", x: 500, y: 500 }, + ]); + expect(errors).toEqual([]); + const rect = byId(elements, "r1"); + expect(rect.x).toBe(500); + expect(rect.version).toBe(before.version + 1); + expect(rect.versionNonce).not.toBe(before.versionNonce); + const label = byId(elements, rect.boundElements[0].id); + expect(label.x + label.width / 2).toBeCloseTo(550, 0); + expect(label.y + label.height / 2).toBeCloseTo(530, 0); + }); + + it("updates label text in place and adds one when missing", () => { + const first = apply( + [], + [ + { op: "add", type: "rect", id: "a", label: "old" }, + { op: "add", type: "rect", id: "b" }, + ] + ); + const { elements, errors } = apply(first.elements, [ + { op: "update", id: "a", label: "new" }, + { op: "update", id: "b", label: "fresh" }, + ]); + expect(errors).toEqual([]); + const aLabel = byId(elements, byId(elements, "a").boundElements[0].id); + expect(aLabel.text).toBe("new"); + const bLabel = byId(elements, byId(elements, "b").boundElements[0].id); + expect(bLabel.text).toBe("fresh"); + // No duplicate label was created for `a`. + expect( + (elements as El[]).filter((e) => e.containerId === "a" && !e.isDeleted) + ).toHaveLength(1); + }); + + it("errors on unknown target", () => { + const { errors } = apply([], [{ op: "update", id: "nope", x: 1 }]); + expect(errors).toHaveLength(1); + }); + + it("re-routes bound arrows when a shape moves", () => { + const first = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 0, w: 100, h: 60 }, + { op: "add", type: "rect", id: "b", x: 300, y: 0, w: 100, h: 60 }, + { op: "add", type: "arrow", id: "ab", from: "a", to: "b" }, + ] + ); + const { elements, errors } = apply(first.elements, [ + { op: "update", id: "b", x: 600, y: 400 }, + ]); + expect(errors).toEqual([]); + const arrow = byId(elements, "ab"); + const endX = arrow.x + arrow.points[1][0]; + const endY = arrow.y + arrow.points[1][1]; + // Arrow now terminates at the moved shape's bounding box, not the old spot. + expect(endX).toBeGreaterThanOrEqual(590); + expect(endX).toBeLessThanOrEqual(710); + expect(endY).toBeGreaterThanOrEqual(390); + expect(endY).toBeLessThanOrEqual(470); + expect(arrow.startBinding).toMatchObject({ elementId: "a" }); + expect(arrow.endBinding).toMatchObject({ elementId: "b" }); + }); +}); + +describe("applyWhiteboardOps: delete", () => { + it("soft-deletes the shape, its label, and unbinds attached arrows", () => { + const first = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 0, label: "a" }, + { op: "add", type: "rect", id: "b", x: 300, y: 0 }, + { op: "add", type: "arrow", id: "ab", from: "a", to: "b" }, + ] + ); + const { elements, errors } = apply(first.elements, [ + { op: "delete", id: "a" }, + ]); + expect(errors).toEqual([]); + expect(byId(elements, "a").isDeleted).toBe(true); + const label = (elements as El[]).find((e) => e.containerId === "a"); + expect(label?.isDeleted).toBe(true); + const arrow = byId(elements, "ab"); + expect(arrow.isDeleted).toBe(false); + expect(arrow.startBinding).toBeNull(); + expect(arrow.endBinding).toMatchObject({ elementId: "b" }); + }); + + it("deleting an arrow scrubs back-references from bound shapes", () => { + const first = apply( + [], + [ + { op: "add", type: "rect", id: "a" }, + { op: "add", type: "rect", id: "b", x: 300 }, + { op: "add", type: "arrow", id: "ab", from: "a", to: "b" }, + ] + ); + const { elements } = apply(first.elements, [{ op: "delete", id: "ab" }]); + expect(byId(elements, "ab").isDeleted).toBe(true); + for (const id of ["a", "b"]) { + const refs = (byId(elements, id).boundElements ?? []) as El[]; + expect(refs.some((r) => r.id === "ab")).toBe(false); + } + }); + + it("deleting a frame frees its children", () => { + const first = apply( + [], + [{ op: "add", type: "frame", id: "f", x: 0, y: 0, w: 400, h: 300 }] + ); + // Simulate an element the editor put inside the frame. + const child = { + ...(first.elements[0] as El), + id: "kid", + type: "rectangle", + frameId: "f", + name: undefined, + }; + const { elements } = apply( + [...first.elements, child], + [{ op: "delete", id: "f" }] + ); + expect(byId(elements, "f").isDeleted).toBe(true); + expect(byId(elements, "kid").frameId).toBeNull(); + expect(byId(elements, "kid").isDeleted).toBe(false); + }); +}); + +describe("applyWhiteboardOps: existing scene preservation", () => { + it("leaves untouched user elements exactly as they were", () => { + const userElement = { + id: "user1", + type: "freedraw", + x: 1, + y: 2, + width: 30, + height: 40, + version: 7, + versionNonce: 42, + isDeleted: false, + points: [ + [0, 0], + [1, 1], + ], + }; + const { elements } = apply( + [userElement], + [{ op: "add", type: "rect", id: "r1" }] + ); + expect(elements[0]).toEqual(userElement); + expect(elements).toHaveLength(2); + }); +}); + +describe("applyWhiteboardOps: label auto-fit", () => { + const CHAR_W = 11; // FONT_SIZE 20 * 0.55, mirrors the builder + + it("wraps a long label to the rect width and grows the height to fit", () => { + const { elements, warnings, errors } = apply( + [], + [ + { + op: "add", + type: "rect", + id: "r1", + x: 0, + y: 0, + w: 160, + h: 70, + label: "streaming and heartbeat tool-layer reconcile plus tiering", + }, + ] + ); + expect(errors).toEqual([]); + const rect = byId(elements, "r1"); + const label = byId(elements, rect.boundElements[0].id); + expect(label.text).toContain("\n"); + expect(label.originalText).toBe( + "streaming and heartbeat tool-layer reconcile plus tiering" + ); + // Every wrapped line fits inside the rect's usable width (w - padding). + expect(label.width).toBeLessThanOrEqual(160 - 10); + // The rect grew tall enough that the text block fits vertically. + expect(rect.width).toBe(160); + expect(rect.height).toBeGreaterThan(70); + expect(label.height).toBeLessThanOrEqual(rect.height - 10); + // Label stays centered on the grown rect. + expect(label.x + label.width / 2).toBeCloseTo(80, 0); + expect(label.y + label.height / 2).toBeCloseTo(rect.height / 2, 0); + expect(warnings.some((w) => w.includes('"r1" grew'))).toBe(true); + }); + + it("wraps ellipse labels at the narrower inscribed width", () => { + const { elements } = apply( + [], + [ + { + op: "add", + type: "ellipse", + id: "e1", + w: 200, + h: 100, + label: "milestone gate review", + }, + ] + ); + const ellipse = byId(elements, "e1"); + const label = byId(elements, ellipse.boundElements[0].id); + // Usable width for an ellipse is w/sqrt(2) - padding, not w - padding. + expect(label.width).toBeLessThanOrEqual(Math.round(200 / Math.SQRT2) - 10); + }); + + it("grows the shape width for one unbreakable word", () => { + const word = "tenant-isolation-tests"; // 22 chars ≈ 242px, box is 100 + const { elements, warnings } = apply( + [], + [{ op: "add", type: "rect", id: "r1", w: 100, h: 60, label: word }] + ); + const rect = byId(elements, "r1"); + const label = byId(elements, rect.boundElements[0].id); + expect(label.text).toBe(word); // no wrap, no hard break + expect(rect.width).toBeGreaterThanOrEqual(word.length * CHAR_W + 10); + expect(warnings.some((w) => w.includes('"r1" grew'))).toBe(true); + }); + + it("hard-breaks words wider than the auto-grow cap", () => { + const word = "x".repeat(60); // 660px > MAX_AUTO_TEXT_WIDTH (440) + const { elements } = apply( + [], + [{ op: "add", type: "rect", id: "r1", w: 100, h: 60, label: word }] + ); + const rect = byId(elements, "r1"); + const label = byId(elements, rect.boundElements[0].id); + expect(label.text).toContain("\n"); + expect(rect.width).toBeLessThanOrEqual(440 + 10); + expect(label.width).toBeLessThanOrEqual(rect.width - 10); + }); + + it("re-wraps and re-grows when an update shrinks a labelled shape", () => { + const first = apply( + [], + [ + { + op: "add", + type: "rect", + id: "r1", + w: 400, + h: 80, + label: "prompt caching and streaming heartbeat", + }, + ] + ); + expect(first.warnings).toEqual([]); + const { elements, warnings } = apply(first.elements, [ + { op: "update", id: "r1", w: 160 }, + ]); + const rect = byId(elements, "r1"); + const label = byId(elements, rect.boundElements[0].id); + expect(label.text).toContain("\n"); + expect(label.width).toBeLessThanOrEqual(160 - 10); + expect(rect.height).toBeGreaterThan(80); + expect(warnings.some((w) => w.includes('"r1" grew'))).toBe(true); + }); + + it("reroutes bound arrows when a label update grows the shape", () => { + const first = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 0, w: 120, h: 60 }, + { op: "add", type: "rect", id: "b", x: 400, y: 0, w: 120, h: 60 }, + { op: "add", type: "arrow", id: "ab", from: "a", to: "b" }, + ] + ); + const arrowBefore = byId(first.elements, "ab"); + const { elements } = apply(first.elements, [ + { + op: "update", + id: "a", + label: "an unreasonably wordy service name that must wrap", + }, + ]); + const arrowAfter = byId(elements, "ab"); + expect(byId(elements, "a").height).toBeGreaterThan(60); + expect(arrowAfter.version).toBeGreaterThan(arrowBefore.version); + // Re-anchored on the grown shape's edge, still pointing at b's center. + expect(arrowAfter.y).toBeGreaterThan(arrowBefore.y); + }); + + it("wraps arrow labels without resizing the arrow", () => { + const { elements, warnings } = apply( + [], + [ + { + op: "add", + type: "arrow", + id: "ar", + x: 0, + y: 0, + w: 100, + h: 0, + label: "proposed fold across both delivery bands for later", + }, + ] + ); + const arrow = byId(elements, "ar"); + expect(arrow.width).toBe(100); + expect(arrow.height).toBe(0); + const label = byId(elements, arrow.boundElements[0].id); + expect(label.text).toContain("\n"); + expect(warnings).toEqual([]); + }); + + it("emits no warnings when labels fit as given", () => { + const { warnings } = apply( + [], + [ + { op: "add", type: "rect", id: "r1", w: 160, h: 70, label: "api" }, + { + op: "add", + type: "rect", + id: "r2", + x: 240, + y: 0, + w: 160, + h: 70, + label: "db", + }, + ] + ); + expect(warnings).toEqual([]); + }); +}); + +describe("applyWhiteboardOps: overlap warnings", () => { + it("warns when a new shape partially overlaps an existing one", () => { + const first = apply( + [], + [{ op: "add", type: "rect", id: "a", x: 0, y: 0, w: 160, h: 70 }] + ); + const { warnings } = apply(first.elements, [ + { op: "add", type: "rect", id: "b", x: 100, y: 30, w: 160, h: 70 }, + ]); + expect(warnings.some((w) => w.includes('"b" overlaps "a"'))).toBe(true); + }); + + it("stays quiet for full containment and clean layouts", () => { + const first = apply( + [], + [{ op: "add", type: "rect", id: "outer", x: 0, y: 0, w: 400, h: 300 }] + ); + const { warnings } = apply(first.elements, [ + { op: "add", type: "rect", id: "inner", x: 50, y: 50, w: 100, h: 60 }, + { op: "add", type: "rect", id: "aside", x: 500, y: 0, w: 100, h: 60 }, + ]); + expect(warnings).toEqual([]); + }); + + it("warns when moving an element onto another", () => { + const first = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 0, w: 160, h: 70 }, + { op: "add", type: "rect", id: "b", x: 300, y: 0, w: 160, h: 70 }, + ] + ); + const { warnings } = apply(first.elements, [ + { op: "update", id: "b", x: 80, y: 20 }, + ]); + expect(warnings.some((w) => w.includes("overlaps"))).toBe(true); + }); +}); + +describe("applyWhiteboardOps: arrow labels", () => { + it("warns when an arrow label lands on an unrelated shape", () => { + const first = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 100, w: 120, h: 60 }, + { op: "add", type: "rect", id: "b", x: 800, y: 100, w: 120, h: 60 }, + // Sits exactly at the a→b midpoint, where the label will land. + { op: "add", type: "rect", id: "mid", x: 400, y: 100, w: 140, h: 60 }, + ] + ); + const { warnings } = apply(first.elements, [ + { + op: "add", + type: "arrow", + id: "ab", + from: "a", + to: "b", + label: "flows", + }, + ]); + expect( + warnings.some((w) => w.includes('label of "ab"') && w.includes('"mid"')) + ).toBe(true); + }); + + it("does not flag an arrow label near its own endpoints", () => { + const { warnings } = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 100, w: 120, h: 60 }, + { op: "add", type: "rect", id: "b", x: 200, y: 100, w: 120, h: 60 }, + { op: "add", type: "arrow", id: "ab", from: "a", to: "b", label: "go" }, + ] + ); + expect(warnings.filter((w) => w.includes("overlaps"))).toEqual([]); + }); + + it("moves an arrow's label along when a shape move reroutes the arrow", () => { + const first = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 0, w: 120, h: 60 }, + { op: "add", type: "rect", id: "b", x: 400, y: 0, w: 120, h: 60 }, + { op: "add", type: "arrow", id: "ab", from: "a", to: "b", label: "go" }, + ] + ); + const arrow = byId(first.elements, "ab"); + const labelId = arrow.boundElements.find( + (r: { type: string }) => r.type === "text" + ).id; + const labelBefore = byId(first.elements, labelId); + const { elements } = apply(first.elements, [ + { op: "update", id: "b", x: 400, y: 600 }, + ]); + const labelAfter = byId(elements, labelId); + expect(labelAfter.y).toBeGreaterThan(labelBefore.y); + // Still centered on the rerouted arrow's bounding box. + const after = byId(elements, "ab"); + expect(labelAfter.x + labelAfter.width / 2).toBeCloseTo( + after.x + after.width / 2, + 0 + ); + }); +}); + +describe("applyWhiteboardOps: stroke style, fill, arrowheads", () => { + it("applies dashed/dotted strokes on add and update", () => { + const first = apply( + [], + [ + { op: "add", type: "rect", id: "r", style: "dashed" }, + { op: "add", type: "arrow", id: "a", x: 0, y: 0, style: "dotted" }, + ] + ); + expect(byId(first.elements, "r").strokeStyle).toBe("dashed"); + expect(byId(first.elements, "a").strokeStyle).toBe("dotted"); + const { elements } = apply(first.elements, [ + { op: "update", id: "r", style: "solid" }, + ]); + expect(byId(elements, "r").strokeStyle).toBe("solid"); + }); + + it("fills shapes with named tints, hex, and transparent; rejects junk", () => { + const { elements, errors } = apply( + [], + [ + { op: "add", type: "rect", id: "r1", fill: "violet" }, + { op: "add", type: "ellipse", id: "e1", fill: "#123456" }, + { op: "add", type: "diamond", id: "d1", fill: "transparent" }, + { op: "add", type: "rect", id: "bad", fill: "plaid" }, + ] + ); + expect(byId(elements, "r1").backgroundColor).toBe("#e5dbff"); + expect(byId(elements, "e1").backgroundColor).toBe("#123456"); + expect(byId(elements, "d1").backgroundColor).toBe("transparent"); + expect(errors).toEqual(['ops[3]: unknown fill "plaid".']); + }); + + it("rejects fill on arrows and heads on non-arrows", () => { + const { errors } = apply( + [], + [ + { op: "add", type: "arrow", id: "a", fill: "red" }, + { op: "add", type: "rect", id: "r", endHead: "dot" }, + { op: "add", type: "rect", id: "r2", via: [[1, 2]] }, + ] + ); + expect(errors).toEqual([ + "ops[0]: fill only applies to rect/ellipse/diamond.", + "ops[1]: startHead/endHead only apply to arrows.", + "ops[2]: via/elbow only apply to arrows and lines.", + ]); + }); + + it("sets custom arrowheads on add and update", () => { + const first = apply( + [], + [ + { + op: "add", + type: "arrow", + id: "a", + startHead: "dot", + endHead: "triangle", + }, + ] + ); + const arrow = byId(first.elements, "a"); + expect(arrow.startArrowhead).toBe("dot"); + expect(arrow.endArrowhead).toBe("triangle"); + const { elements } = apply(first.elements, [ + { op: "update", id: "a", startHead: "none", endHead: "bar" }, + ]); + expect(byId(elements, "a").startArrowhead).toBeNull(); + expect(byId(elements, "a").endArrowhead).toBe("bar"); + }); +}); + +describe("applyWhiteboardOps: arrow bends (via) and elbow routing", () => { + it("builds a curved multi-point arrow from via waypoints", () => { + const { elements, errors } = apply( + [], + [ + { + op: "add", + type: "arrow", + id: "a", + x: 0, + y: 0, + w: 200, + h: 0, + via: [[100, 80]], + }, + ] + ); + expect(errors).toEqual([]); + const arrow = byId(elements, "a"); + expect(arrow.points).toEqual([ + [0, 0], + [100, 80], + [200, 0], + ]); + expect(arrow.width).toBe(200); + expect(arrow.height).toBe(80); // bbox extent, not endpoint dy + expect(arrow.roundness).toEqual({ type: 2 }); // smooth bends + }); + + it("aims bound endpoints at the nearest bend, not the far element", () => { + const { elements } = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 0, w: 100, h: 100 }, + { op: "add", type: "rect", id: "b", x: 400, y: 0, w: 100, h: 100 }, + { + op: "add", + type: "arrow", + id: "ab", + from: "a", + to: "b", + via: [[250, 300]], + }, + ] + ); + const arrow = byId(elements, "ab"); + // Bend is below both boxes, so the arrow leaves a's bottom half, not its + // right edge midpoint (which aiming at b's center would produce). + expect(arrow.y).toBeGreaterThan(50); + expect(arrow.points.length).toBe(3); + }); + + it("routes elbow arrows orthogonally between boxes and stamps customData", () => { + const { elements } = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 0, w: 100, h: 60 }, + { op: "add", type: "rect", id: "b", x: 400, y: 300, w: 100, h: 60 }, + { op: "add", type: "arrow", id: "ab", from: "a", to: "b", elbow: true }, + ] + ); + const arrow = byId(elements, "ab"); + expect(arrow.customData.elbow).toBe(true); + expect(arrow.roundness).toBeNull(); // elbows stay crisp + const pts = arrow.points as number[][]; + expect(pts.length).toBe(4); + // Every segment is axis-aligned. + for (let i = 1; i < pts.length; i++) { + const straight = + pts[i][0] === pts[i - 1][0] || pts[i][1] === pts[i - 1][1]; + expect(straight, `segment ${i}`).toBe(true); + } + // Leaves a's right edge, enters b's left edge. + expect(arrow.x).toBe(100); + expect(arrow.y).toBe(30); + expect(arrow.x + pts[3][0]).toBe(400); + expect(arrow.y + pts[3][1]).toBe(330); + }); + + it("re-routes elbow arrows orthogonally when a bound shape moves", () => { + const first = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 0, w: 100, h: 60 }, + { op: "add", type: "rect", id: "b", x: 400, y: 300, w: 100, h: 60 }, + { op: "add", type: "arrow", id: "ab", from: "a", to: "b", elbow: true }, + ] + ); + const { elements } = apply(first.elements, [ + { op: "update", id: "b", x: 0, y: 500 }, + ]); + const arrow = byId(elements, "ab"); + const pts = arrow.points as number[][]; + for (let i = 1; i < pts.length; i++) { + const straight = + pts[i][0] === pts[i - 1][0] || pts[i][1] === pts[i - 1][1]; + expect(straight, `segment ${i}`).toBe(true); + } + // Now stacked vertically: leaves a's bottom edge. + expect(arrow.x).toBe(50); + expect(arrow.y).toBe(60); + }); + + it("keeps hand-set bends in place when a bound shape moves", () => { + const first = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 0, w: 100, h: 100 }, + { op: "add", type: "rect", id: "b", x: 400, y: 0, w: 100, h: 100 }, + { + op: "add", + type: "arrow", + id: "ab", + from: "a", + to: "b", + via: [[250, 300]], + }, + ] + ); + const before = byId(first.elements, "ab"); + const bendBefore = { + x: before.x + before.points[1][0], + y: before.y + before.points[1][1], + }; + const { elements } = apply(first.elements, [ + { op: "update", id: "b", x: 600, y: 200 }, + ]); + const after = byId(elements, "ab"); + expect(after.points.length).toBe(3); + expect(after.x + after.points[1][0]).toBeCloseTo(bendBefore.x, 5); + expect(after.y + after.points[1][1]).toBeCloseTo(bendBefore.y, 5); + }); + + it("adds and removes bends via update", () => { + const first = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 0, w: 100, h: 100 }, + { op: "add", type: "rect", id: "b", x: 400, y: 0, w: 100, h: 100 }, + { op: "add", type: "arrow", id: "ab", from: "a", to: "b" }, + ] + ); + const bent = apply(first.elements, [ + { op: "update", id: "ab", via: [[250, 250]] }, + ]); + const arrow = byId(bent.elements, "ab"); + expect(arrow.points.length).toBe(3); + expect(arrow.roundness).toEqual({ type: 2 }); + const straightened = apply(bent.elements, [ + { op: "update", id: "ab", via: [] }, + ]); + const back = byId(straightened.elements, "ab"); + expect(back.points.length).toBe(2); + expect(back.roundness).toBeNull(); + }); + + it("recenters an arrow label on its bent path midpoint", () => { + const { elements } = apply( + [], + [ + { + op: "add", + type: "arrow", + id: "a", + x: 0, + y: 0, + w: 200, + h: 0, + via: [[100, 200]], + label: "hop", + }, + ] + ); + const arrow = byId(elements, "a"); + const labelId = arrow.boundElements.find( + (r: { type: string }) => r.type === "text" + ).id; + const label = byId(elements, labelId); + // Bbox is 200x200 starting at (0,0); center is (100, 100). + expect(label.x + label.width / 2).toBeCloseTo(100, 0); + expect(label.y + label.height / 2).toBeCloseTo(100, 0); + }); +}); + +describe("applyWhiteboardOps: arrow crossing warnings", () => { + const crossings = (warnings: string[]) => + warnings.filter((w) => w.includes("passes through")); + + it("warns when a straight arrow slices through an unrelated box", () => { + const { warnings } = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 100, w: 100, h: 60 }, + { op: "add", type: "rect", id: "mid", x: 300, y: 100, w: 100, h: 60 }, + { op: "add", type: "rect", id: "b", x: 600, y: 100, w: 100, h: 60 }, + { op: "add", type: "arrow", id: "ab", from: "a", to: "b" }, + ] + ); + expect(crossings(warnings)).toEqual([ + '"ab" passes through "mid" — reroute it around (elbow:true or via ' + + "bend points) or move one of them.", + ]); + }); + + it("stays quiet for bound endpoints and arrows routed around", () => { + const { warnings } = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 100, w: 100, h: 60 }, + { op: "add", type: "rect", id: "mid", x: 300, y: 100, w: 100, h: 60 }, + { op: "add", type: "rect", id: "b", x: 600, y: 100, w: 100, h: 60 }, + { + op: "add", + type: "arrow", + id: "ab", + from: "a", + to: "b", + via: [[350, 320]], + }, + ] + ); + expect(crossings(warnings)).toEqual([]); + }); + + it("warns when a shape is moved onto an existing arrow's path", () => { + const first = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 100, w: 100, h: 60 }, + { op: "add", type: "rect", id: "b", x: 600, y: 100, w: 100, h: 60 }, + { op: "add", type: "arrow", id: "ab", from: "a", to: "b" }, + { op: "add", type: "rect", id: "float", x: 300, y: 500, w: 100, h: 60 }, + ] + ); + expect(crossings(first.warnings)).toEqual([]); + const { warnings } = apply(first.elements, [ + { op: "update", id: "float", x: 300, y: 100 }, + ]); + expect(crossings(warnings)).toEqual([ + '"ab" passes through "float" — reroute it around (elbow:true or via ' + + "bend points) or move one of them.", + ]); + }); + + it("clears the warning once the arrow is rerouted with via", () => { + const first = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 100, w: 100, h: 60 }, + { op: "add", type: "rect", id: "mid", x: 300, y: 100, w: 100, h: 60 }, + { op: "add", type: "rect", id: "b", x: 600, y: 100, w: 100, h: 60 }, + { op: "add", type: "arrow", id: "ab", from: "a", to: "b" }, + ] + ); + expect(crossings(first.warnings)).toHaveLength(1); + const { warnings } = apply(first.elements, [ + { op: "update", id: "ab", via: [[350, 350]] }, + ]); + expect(crossings(warnings)).toEqual([]); + }); + + it("checks every segment of a bent arrow, not just the chord", () => { + const { warnings } = apply( + [], + [ + { op: "add", type: "rect", id: "box", x: 300, y: 300, w: 100, h: 60 }, + { + op: "add", + type: "arrow", + id: "hook", + x: 0, + y: 0, + w: 700, + h: 0, + // Endpoints' straight chord misses the box; the bend dips into it. + via: [[350, 330]], + }, + ] + ); + expect(crossings(warnings)).toHaveLength(1); + }); +}); + +describe("applyWhiteboardOps: connectors render beneath shapes", () => { + it("moves agent arrows to the front of the element list", () => { + const { elements } = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 0, w: 100, h: 60 }, + { op: "add", type: "rect", id: "b", x: 300, y: 0, w: 100, h: 60 }, + { op: "add", type: "arrow", id: "ab", from: "a", to: "b", label: "x" }, + ] + ); + const ids = (elements as El[]).map((e) => e.id); + expect(ids[0]).toBe("ab"); // connector at the back of the z-order + // Its label stays above the shapes, at its original position. + expect(ids.indexOf("a")).toBeLessThan( + ids.findIndex((id) => byId(elements, id as string).containerId === "ab") + ); + }); + + it("nulls the fractional index of displaced connectors only", () => { + // Simulate a scene the editor already indexed: shape then arrow. + const first = apply( + [], + [ + { op: "add", type: "rect", id: "a", x: 0, y: 0, w: 100, h: 60 }, + { op: "add", type: "arrow", id: "ar", x: 200, y: 200, w: 100, h: 0 }, + ] + ); + const els = first.elements as El[]; + const indexed = [ + { ...byId(els, "a"), index: "a0" }, + { ...byId(els, "ar"), index: "a1" }, + ]; + const { elements } = apply(indexed, [{ op: "update", id: "a", x: 10 }]); + const ids = (elements as El[]).map((e) => e.id); + expect(ids[0]).toBe("ar"); + expect(byId(elements, "ar").index).toBeNull(); // editor re-assigns + expect(byId(elements, "a").index).toBe("a0"); // user order untouched + }); + + it("leaves user-drawn arrows where the user put them", () => { + const userArrow = { + id: "user-arrow", + type: "arrow", + x: 0, + y: 0, + width: 100, + height: 0, + points: [ + [0, 0], + [100, 0], + ], + isDeleted: false, + }; + const { elements } = apply( + [userArrow], + [{ op: "add", type: "rect", id: "r", x: 300, y: 300, w: 100, h: 60 }] + ); + const ids = (elements as El[]).map((e) => e.id); + expect(ids).toEqual(["user-arrow", "r"]); + }); +}); + +// The builder mirrors Excalidraw internals captured from a specific version; +// force a re-verification of those formulas whenever the web pin moves. +describe("excalidraw capture version", () => { + it("matches the @excalidraw/excalidraw pin in apps/web", async () => { + const { EXCALIDRAW_CAPTURE_VERSION } = + await import("../src/shared/whiteboard-builder.js"); + const { readFile } = await import("node:fs/promises"); + const pkg = JSON.parse( + await readFile(new URL("../../web/package.json", import.meta.url), "utf8") + ) as { dependencies: Record }; + expect(pkg.dependencies["@excalidraw/excalidraw"]).toBe( + EXCALIDRAW_CAPTURE_VERSION + ); + }); +}); diff --git a/apps/server/test/whiteboard.test.ts b/apps/server/test/whiteboard.test.ts new file mode 100644 index 00000000..4a5b77d4 --- /dev/null +++ b/apps/server/test/whiteboard.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { simplifyElements } from "../src/shared/whiteboard.js"; +import { isValidScene } from "../src/shared/whiteboard-store.js"; + +describe("simplifyElements", () => { + it("keeps geometry, text, and bindings while stripping style noise", () => { + const raw = [ + { + id: "rect1", + type: "rectangle", + x: 10.7, + y: 20.2, + width: 200, + height: 90, + angle: 0, + strokeColor: "#846358", + backgroundColor: "transparent", + seed: 12345, + versionNonce: 99999, + isDeleted: false, + }, + { + id: "label1", + type: "text", + x: 50, + y: 40, + width: 70, + height: 25, + text: "Web UI", + containerId: "rect1", + strokeColor: "#846358", + }, + { + id: "arrow1", + type: "arrow", + x: 0, + y: 0, + width: 100, + height: 0, + startBinding: { elementId: "rect1", focus: 0, gap: 5 }, + endBinding: { elementId: "rect2", focus: 0, gap: 5 }, + }, + ]; + + const out = simplifyElements(raw); + expect(out).toHaveLength(3); + expect(out[0]).toEqual({ + id: "rect1", + type: "rectangle", + x: 11, + y: 20, + width: 200, + height: 90, + strokeColor: "#846358", + }); + expect(out[1].text).toBe("Web UI"); + expect(out[1].containerId).toBe("rect1"); + expect(out[2].from).toBe("rect1"); + expect(out[2].to).toBe("rect2"); + expect(out[0]).not.toHaveProperty("seed"); + expect(out[0]).not.toHaveProperty("versionNonce"); + expect(out[0]).not.toHaveProperty("backgroundColor"); + }); + + it("drops deleted elements and tolerates malformed entries", () => { + const out = simplifyElements([ + { id: "gone", type: "rectangle", isDeleted: true }, + null, + "junk", + { id: "ok", type: "ellipse", x: 1, y: 2, width: 3, height: 4 }, + ]); + expect(out.map((e) => e.id)).toEqual(["ok"]); + }); +}); + +describe("isValidScene", () => { + it("accepts an object with an elements array", () => { + expect(isValidScene({ elements: [] })).toBe(true); + expect(isValidScene({ elements: [{ type: "rectangle" }] })).toBe(true); + }); + + it("rejects non-objects and missing/oversized element lists", () => { + expect(isValidScene(null)).toBe(false); + expect(isValidScene("nope")).toBe(false); + expect(isValidScene({})).toBe(false); + expect(isValidScene({ elements: "x" })).toBe(false); + expect(isValidScene({ elements: new Array(20_001).fill({}) })).toBe(false); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 2a273373..e97ec926 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@excalidraw/excalidraw": "0.18.1", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index c763b248..93f53dde 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -1,8 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Routes, Route, useParams } from "react-router-dom"; import { PanelLeftOpen, PanelRightOpen } from "lucide-react"; +import { useAtomValue } from "jotai"; + +import { whiteboardAgentDrewAtomFamily } from "@/lib/store"; import { ChangesTab } from "@/components/app/changes-tab"; +import { WhiteboardPane } from "@/components/app/whiteboard-pane"; import { ChangesSettingsPopover } from "@/components/app/changes-settings-popover"; import { CenterPaneTabBar } from "@/components/app/center-pane-tab-bar"; import { useAgentDiffStats } from "@/hooks/use-agent-diff-stats"; @@ -116,6 +120,7 @@ export function AgentsView({ const { changesMatch, + whiteboardMatch, feedbackDetail, feedbackDetailRendered, handleFeedbackTransitionEnd, @@ -217,6 +222,12 @@ export function AgentsView({ ? (agents.find((agent) => agent.id === focusedAgentId) ?? null) : null; + // "Agent drew" dot on the Whiteboard tab: lit by SSE (use-sse.ts), + // cleared by WhiteboardPane while the user is looking at that board. + const whiteboardAgentDrew = useAtomValue( + whiteboardAgentDrewAtomFamily(focusedAgentId ?? "") + ); + const { mediaFiles, animatingMediaKeys, @@ -525,9 +536,16 @@ export function AgentsView({ {focusedAgent.name} ) : null} @@ -563,7 +581,12 @@ export function AgentsView({
-
+
+ {!isMobile ? (
diff --git a/apps/web/src/components/app/center-pane-tab-bar.tsx b/apps/web/src/components/app/center-pane-tab-bar.tsx index ab17e863..3d5722b9 100644 --- a/apps/web/src/components/app/center-pane-tab-bar.tsx +++ b/apps/web/src/components/app/center-pane-tab-bar.tsx @@ -3,18 +3,20 @@ import { memo } from "react"; import type { DiffStats } from "@/components/app/types"; import { cn } from "@/lib/utils"; -type CenterTab = "terminal" | "changes"; +type CenterTab = "terminal" | "changes" | "whiteboard"; type CenterPaneTabBarProps = { activeTab: CenterTab; onTabChange: (tab: CenterTab) => void; diffStats: DiffStats | null | undefined; + whiteboardAgentDrew?: boolean; }; export const CenterPaneTabBar = memo(function CenterPaneTabBar({ activeTab, onTabChange, diffStats, + whiteboardAgentDrew = false, }: CenterPaneTabBarProps): JSX.Element { const hasChanges = diffStats && (diffStats.added > 0 || diffStats.deleted > 0); @@ -67,6 +69,32 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({ ) : null} +
); }); diff --git a/apps/web/src/components/app/whiteboard-pane.tsx b/apps/web/src/components/app/whiteboard-pane.tsx new file mode 100644 index 00000000..1cb1dfb0 --- /dev/null +++ b/apps/web/src/components/app/whiteboard-pane.tsx @@ -0,0 +1,49 @@ +import { lazy, Suspense, useEffect, useState } from "react"; +import { useAtom } from "jotai"; + +import { whiteboardAgentDrewAtomFamily } from "@/lib/store"; +import { cn } from "@/lib/utils"; + +// Excalidraw is heavy (~MBs); split it out and only load on first tab visit. +const WhiteboardTab = lazy(() => import("@/components/app/whiteboard-tab")); + +type WhiteboardPaneProps = { + agentId: string | null; + active: boolean; +}; + +// Owns all whiteboard pane concerns so agents-view stays a composition root: +// mount-on-first-visit then keep mounted (Terminal pattern — undo history, +// zoom, and in-flight strokes survive tab switches), and clearing the +// "agent drew" dot (lit by SSE in use-sse.ts) while the user is looking. +export function WhiteboardPane({ + agentId, + active, +}: WhiteboardPaneProps): JSX.Element | null { + const [opened, setOpened] = useState(false); + useEffect(() => { + if (active) setOpened(true); + }, [active]); + + const [agentDrew, setAgentDrew] = useAtom( + whiteboardAgentDrewAtomFamily(agentId ?? "") + ); + useEffect(() => { + if (active && agentId && agentDrew) setAgentDrew(false); + }, [active, agentId, agentDrew, setAgentDrew]); + + if (!opened || !agentId) return null; + return ( +
+ + Loading whiteboard… +
+ } + > + + +
+ ); +} diff --git a/apps/web/src/components/app/whiteboard-tab.tsx b/apps/web/src/components/app/whiteboard-tab.tsx new file mode 100644 index 00000000..e7fa43b7 --- /dev/null +++ b/apps/web/src/components/app/whiteboard-tab.tsx @@ -0,0 +1,305 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Excalidraw, + exportToBlob, + getSceneVersion, +} from "@excalidraw/excalidraw"; +import type { + ExcalidrawImperativeAPI, + ExcalidrawInitialDataState, +} from "@excalidraw/excalidraw/types"; +import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types"; +import "@excalidraw/excalidraw/index.css"; + +import { + useWhiteboard, + whiteboardQueryKey, + type WhiteboardData, +} from "@/hooks/use-whiteboard"; +import { getThemeMode, useTheme } from "@/hooks/use-theme"; +import { api } from "@/lib/api"; + +declare global { + interface Window { + EXCALIDRAW_ASSET_PATH?: string; + } +} +// Self-hosted fonts (see excalidrawAssets in vite.config.ts); without this +// Excalidraw fetches them from esm.sh at runtime. +window.EXCALIDRAW_ASSET_PATH = "/excalidraw/"; + +const SAVE_DEBOUNCE_MS = 1000; +const SNAPSHOT_DEBOUNCE_MS = 4000; + +type WhiteboardTabProps = { + agentId: string; + visible: boolean; +}; + +export default function WhiteboardTab({ + agentId, + visible, +}: WhiteboardTabProps): JSX.Element { + const { data, isLoading, isError } = useWhiteboard(agentId); + + if (isLoading || (!data && !isError)) { + return ( +
+ Loading whiteboard… +
+ ); + } + if (isError || !data) { + return ( +
+ Could not load the whiteboard. +
+ ); + } + return ( + + ); +} + +function WhiteboardCanvas({ + agentId, + initial, + visible, +}: { + agentId: string; + initial: WhiteboardData; + visible: boolean; +}): JSX.Element { + const queryClient = useQueryClient(); + const { theme } = useTheme(); + const [excalidrawAPI, setExcalidrawAPI] = + useState(null); + const [boardEmpty, setBoardEmpty] = useState( + initial.scene.elements.length === 0 + ); + const { data } = useWhiteboard(agentId); + + // Server board version our last save was based on; bumped on each save. + const versionRef = useRef(initial.version); + // Content hash of the last persisted (or initial) scene, to skip no-op saves. + const sceneVersionRef = useRef( + getSceneVersion(initial.scene.elements as readonly ExcalidrawElement[]) + ); + const saveTimerRef = useRef(undefined); + const snapshotTimerRef = useRef(undefined); + const savingRef = useRef(false); + // Remote scene waiting to be applied while the user is mid-gesture. + const pointerDownRef = useRef(false); + const pendingRemoteRef = useRef(null); + + const initialData = useMemo( + () => ({ + elements: initial.scene.elements as readonly ExcalidrawElement[], + scrollToContent: true, + }), + [initial] + ); + + const persistSnapshot = useCallback(async () => { + if (!excalidrawAPI) return; + const elements = excalidrawAPI.getSceneElements(); + if (elements.length === 0) { + // An emptied board must also drop its PNG, or whiteboard_get keeps + // showing agents the erased drawing. + try { + await api(`/api/v1/agents/${agentId}/whiteboard/snapshot`, { + method: "DELETE", + }); + } catch {} + return; + } + try { + const blob = await exportToBlob({ + elements, + appState: { + ...excalidrawAPI.getAppState(), + exportBackground: true, + }, + files: excalidrawAPI.getFiles(), + mimeType: "image/png", + }); + const form = new FormData(); + form.append("file", blob, "whiteboard.png"); + await api(`/api/v1/agents/${agentId}/whiteboard/snapshot`, { + method: "POST", + body: form, + }); + } catch { + // Snapshot is best-effort; the scene itself is already persisted. + } + }, [agentId, excalidrawAPI]); + + const persistScene = useCallback(async () => { + if (!excalidrawAPI || savingRef.current) return; + const elements = excalidrawAPI.getSceneElements(); + const sceneVersion = getSceneVersion(elements); + if (sceneVersion === sceneVersionRef.current) return; + savingRef.current = true; + try { + const res = await api<{ version: number }>( + `/api/v1/agents/${agentId}/whiteboard`, + { + method: "PUT", + body: JSON.stringify({ + scene: { elements }, + baseVersion: versionRef.current, + }), + } + ); + versionRef.current = res.version; + sceneVersionRef.current = sceneVersion; + queryClient.setQueryData( + whiteboardQueryKey(agentId), + (old) => + old + ? { + ...old, + scene: { elements: [...elements] }, + version: res.version, + } + : old + ); + // Content changed on the server; refresh the agent-facing PNG. + if (snapshotTimerRef.current !== undefined) { + window.clearTimeout(snapshotTimerRef.current); + } + snapshotTimerRef.current = window.setTimeout(() => { + void persistSnapshot(); + }, SNAPSHOT_DEBOUNCE_MS); + } catch { + // Version conflict or transient failure: refetch so the editor + // reconciles against the server's copy. + void queryClient.invalidateQueries({ + queryKey: whiteboardQueryKey(agentId), + exact: true, + }); + } finally { + savingRef.current = false; + } + }, [agentId, excalidrawAPI, persistSnapshot, queryClient]); + + const scheduleSave = useCallback(() => { + if (excalidrawAPI) { + setBoardEmpty(excalidrawAPI.getSceneElements().length === 0); + } + if (saveTimerRef.current !== undefined) { + window.clearTimeout(saveTimerRef.current); + } + saveTimerRef.current = window.setTimeout(() => { + saveTimerRef.current = undefined; + void persistScene(); + }, SAVE_DEBOUNCE_MS); + }, [excalidrawAPI, persistScene]); + + const applyRemote = useCallback( + (remote: WhiteboardData) => { + if (!excalidrawAPI) return; + versionRef.current = remote.version; + sceneVersionRef.current = getSceneVersion( + remote.scene.elements as readonly ExcalidrawElement[] + ); + excalidrawAPI.updateScene({ + elements: remote.scene.elements as ExcalidrawElement[], + }); + setBoardEmpty(remote.scene.elements.length === 0); + // Agent edits happen server-side where no PNG can be rendered; this + // client just became the renderer — refresh the agent-facing snapshot. + if (snapshotTimerRef.current !== undefined) { + window.clearTimeout(snapshotTimerRef.current); + } + snapshotTimerRef.current = window.setTimeout(() => { + void persistSnapshot(); + }, SNAPSHOT_DEBOUNCE_MS); + }, + [excalidrawAPI, persistSnapshot] + ); + + // Apply remote (agent/SSE-driven) updates; defer while a gesture is live. + useEffect(() => { + if (!data || data.version <= versionRef.current) return; + if (pointerDownRef.current) { + pendingRemoteRef.current = data; + return; + } + applyRemote(data); + }, [data, applyRemote]); + + useEffect(() => { + const onPointerUp = () => { + pointerDownRef.current = false; + const pending = pendingRemoteRef.current; + if (pending) { + pendingRemoteRef.current = null; + applyRemote(pending); + } + }; + window.addEventListener("pointerup", onPointerUp); + return () => window.removeEventListener("pointerup", onPointerUp); + }, [applyRemote]); + + // Flush pending work when the tab is hidden or the canvas unmounts. + useEffect(() => { + if (visible) return; + if (saveTimerRef.current !== undefined) { + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = undefined; + void persistScene(); + } + }, [visible, persistScene]); + + useEffect(() => { + return () => { + if (saveTimerRef.current !== undefined) { + window.clearTimeout(saveTimerRef.current); + void persistScene(); + } + if (snapshotTimerRef.current !== undefined) { + window.clearTimeout(snapshotTimerRef.current); + // Flush like the save timer: exportToBlob renders offscreen from + // scene data, so it still works during teardown. + void persistSnapshot(); + } + }; + // persistScene is stable per agent/api instance; run cleanup once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
{ + pointerDownRef.current = true; + }} + > + {boardEmpty ? ( +
+

+ Sketch here — your agent can see this board. Ask it to “look + at the whiteboard” in the terminal. +

+
+ ) : null} + +
+ ); +} diff --git a/apps/web/src/hooks/use-agents-view-routing.ts b/apps/web/src/hooks/use-agents-view-routing.ts index 6fc17d42..d934e898 100644 --- a/apps/web/src/hooks/use-agents-view-routing.ts +++ b/apps/web/src/hooks/use-agents-view-routing.ts @@ -8,6 +8,7 @@ import { agentFeedbackRoute, agentReviewRoute, agentRoute, + agentWhiteboardRoute, } from "@/lib/agent-routes"; type UseAgentsViewRoutingOptions = { @@ -27,6 +28,7 @@ export function useAgentsViewRouting({ const feedbackMatch = useMatch("/agents/:agentId/feedback/:itemId"); const reviewMatch = useMatch("/agents/:agentId/review/:summaryAgentId"); const changesMatch = useMatch("/agents/:agentId/changes"); + const whiteboardMatch = useMatch("/agents/:agentId/whiteboard"); const itemId = feedbackMatch?.params.itemId; const summaryAgentId = reviewMatch?.params.summaryAgentId; @@ -74,12 +76,14 @@ export function useAgentsViewRouting({ }, [agents, agentsLoaded, navigate, routeAgentId, summaryAgentId]); const onTabChange = useCallback( - (tab: "terminal" | "changes") => { + (tab: "terminal" | "changes" | "whiteboard") => { if (!routeAgentId) return; navigate( tab === "changes" ? agentChangesRoute(routeAgentId) - : agentRoute(routeAgentId), + : tab === "whiteboard" + ? agentWhiteboardRoute(routeAgentId) + : agentRoute(routeAgentId), { replace: true } ); }, @@ -128,6 +132,7 @@ export function useAgentsViewRouting({ return { changesMatch: !!changesMatch, + whiteboardMatch: !!whiteboardMatch, feedbackDetail, feedbackDetailRendered, handleFeedbackTransitionEnd, diff --git a/apps/web/src/hooks/use-sse.ts b/apps/web/src/hooks/use-sse.ts index d73d163a..26a5eff7 100644 --- a/apps/web/src/hooks/use-sse.ts +++ b/apps/web/src/hooks/use-sse.ts @@ -1,5 +1,6 @@ import { useEffect, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { useStore } from "jotai"; import { type Agent, type AuthState, @@ -11,6 +12,7 @@ import { agentDiffQueryKey } from "@/hooks/use-agent-diff"; import { diffStatsQueryKey } from "@/hooks/use-agent-diff-stats"; import { sortAgentsByCreatedAtDesc } from "@/lib/agent-sort"; import { recordSSEEvent, recordSSEReconnect } from "@/lib/energy-metrics"; +import { whiteboardAgentDrewAtomFamily } from "@/lib/store"; import { showWebNotification } from "@/lib/web-notifications"; import { CACHED_RELEASE_INFO_QUERY_KEY, @@ -32,6 +34,12 @@ type UiEvent = } | { type: "agent.deleted"; agentId: string } | { type: "media.changed"; agentId: string } + | { + type: "whiteboard.changed"; + agentId: string; + version: number; + source: "user" | "agent"; + } | { type: "media.seen"; agentId: string; keys: string[] } | { type: "stream.started"; agentId: string } | { type: "stream.stopped"; agentId: string } @@ -67,6 +75,7 @@ function patchAgentHasStream( export function useSSE(authState: AuthState): void { const queryClient = useQueryClient(); + const jotaiStore = useStore(); const eventSourceRef = useRef(null); useEffect(() => { @@ -89,6 +98,9 @@ export function useSSE(authState: AuthState): void { void queryClient.invalidateQueries({ queryKey: ["jobs"] }); void queryClient.invalidateQueries({ queryKey: ["templates"] }); void queryClient.invalidateQueries({ queryKey: ["brain"] }); + // The SSE stream closes while the tab is hidden; an agent may + // have drawn on a whiteboard in the gap. + void queryClient.invalidateQueries({ queryKey: ["whiteboard"] }); void queryClient.invalidateQueries({ queryKey: CACHED_RELEASE_INFO_QUERY_KEY, }); @@ -144,6 +156,23 @@ export function useSSE(authState: AuthState): void { return; } + if (payload.type === "whiteboard.changed") { + // Only remote (agent) edits need a refetch: the local editor is + // the source of user edits and already has them. + if (payload.source === "agent") { + void queryClient.invalidateQueries({ + queryKey: ["whiteboard", payload.agentId], + exact: true, + }); + // Light the tab's "agent drew" dot; viewing the tab clears it. + jotaiStore.set( + whiteboardAgentDrewAtomFamily(payload.agentId), + true + ); + } + return; + } + if (payload.type === "stream.started") { patchAgentHasStream(queryClient, payload.agentId, true); return; @@ -251,5 +280,5 @@ export function useSSE(authState: AuthState): void { document.removeEventListener("visibilitychange", onVisChange); closeSSE(); }; - }, [authState, queryClient]); + }, [authState, queryClient, jotaiStore]); } diff --git a/apps/web/src/hooks/use-theme.ts b/apps/web/src/hooks/use-theme.ts index df5085a2..36399a56 100644 --- a/apps/web/src/hooks/use-theme.ts +++ b/apps/web/src/hooks/use-theme.ts @@ -417,6 +417,10 @@ function applyTheme(themeId: ThemeId): void { } } +export function getThemeMode(themeId: ThemeId): "light" | "dark" { + return THEMES.find((t) => t.id === themeId)?.mode ?? "dark"; +} + export function useTheme(): { theme: ThemeId; setTheme: (id: ThemeId) => void; diff --git a/apps/web/src/hooks/use-whiteboard.ts b/apps/web/src/hooks/use-whiteboard.ts new file mode 100644 index 00000000..ed6b9f7a --- /dev/null +++ b/apps/web/src/hooks/use-whiteboard.ts @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api } from "@/lib/api"; + +export type WhiteboardData = { + scene: { elements: unknown[] }; + version: number; + updatedAt: string | null; +}; + +export function whiteboardQueryKey(agentId: string): [string, string] { + return ["whiteboard", agentId]; +} + +// staleTime: Infinity — SSE (whiteboard.changed / reconnect snapshot) +// drives invalidation; the local editor owns all other changes. +export function useWhiteboard(agentId: string | null) { + return useQuery({ + queryKey: whiteboardQueryKey(agentId ?? ""), + queryFn: () => api(`/api/v1/agents/${agentId}/whiteboard`), + enabled: !!agentId, + staleTime: Infinity, + }); +} diff --git a/apps/web/src/lib/agent-routes.ts b/apps/web/src/lib/agent-routes.ts index 341813e7..914fb519 100644 --- a/apps/web/src/lib/agent-routes.ts +++ b/apps/web/src/lib/agent-routes.ts @@ -6,6 +6,10 @@ export function agentChangesRoute(agentId: string): string { return `/agents/${agentId}/changes`; } +export function agentWhiteboardRoute(agentId: string): string { + return `/agents/${agentId}/whiteboard`; +} + export function agentFeedbackRoute(agentId: string, itemId: number): string { return `/agents/${agentId}/feedback/${itemId}`; } diff --git a/apps/web/src/lib/store.ts b/apps/web/src/lib/store.ts index d5254630..36941b9e 100644 --- a/apps/web/src/lib/store.ts +++ b/apps/web/src/lib/store.ts @@ -92,6 +92,13 @@ export const dismissedReleaseToastAtomFamily = atomFamily((tag: string) => atomWithLocalStorage(`dispatch:dismissedReleaseToast:${tag}`, false) ); +// Set when an agent draws on a whiteboard (SSE `whiteboard.changed`, +// source "agent"); cleared when the user views that agent's whiteboard +// tab. Drives the "agent drew" dot on the Whiteboard tab. Ephemeral. +export const whiteboardAgentDrewAtomFamily = atomFamily((_agentId: string) => + atom(false) +); + export type DiffViewType = "unified" | "split"; export const diffViewTypeAtom = atomWithLocalStorage( diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index debbf27f..017cb1a7 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,11 +1,55 @@ -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; import { VitePWA } from "vite-plugin-pwa"; import path from "node:path"; -import { readFileSync } from "node:fs"; +import { readFileSync, cpSync, existsSync } from "node:fs"; const isProd = process.env.NODE_ENV === "production"; +// Excalidraw lazy-fetches its fonts from esm.sh unless +// window.EXCALIDRAW_ASSET_PATH points at self-hosted copies (set in +// whiteboard-tab.tsx). Serve them from node_modules in dev and copy them +// into dist/ for the embedded-static prod build. Xiaolai (CJK, 12 MB of +// the 13 MB fonts dir) is deliberately excluded: it is only requested for +// CJK glyphs and missing fonts fall back to system fonts. +function excalidrawAssets(): Plugin { + const fontsDir = path.resolve( + __dirname, + "node_modules/@excalidraw/excalidraw/dist/prod/fonts" + ); + const publicPrefix = "/excalidraw/fonts/"; + const skipFamilies = new Set(["Xiaolai"]); + return { + name: "excalidraw-assets", + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = (req.url ?? "").split("?")[0]; + if (!url.startsWith(publicPrefix)) return next(); + const rel = decodeURIComponent(url.slice(publicPrefix.length)); + const file = path.join(fontsDir, rel); + if (!file.startsWith(fontsDir) || !existsSync(file)) { + res.statusCode = 404; + return res.end(); + } + res.setHeader( + "Content-Type", + file.endsWith(".woff2") ? "font/woff2" : "font/woff" + ); + return res.end(readFileSync(file)); + }); + }, + writeBundle(options) { + const outDir = options.dir ?? path.resolve(__dirname, "dist"); + cpSync(fontsDir, path.join(outDir, "excalidraw/fonts"), { + recursive: true, + filter: (src) => + !skipFamilies.has(path.basename(path.dirname(src))) && + !skipFamilies.has(path.basename(src)), + }); + }, + }; +} + // Bake the workspace version into the bundle. The web client compares // this against the `X-Dispatch-Version` response header to detect a // stale bundle after a server self-update. @@ -35,6 +79,7 @@ export default defineConfig({ }, plugins: [ react(), + excalidrawAssets(), isProd && VitePWA({ registerType: "prompt", diff --git a/e2e/whiteboard.spec.ts b/e2e/whiteboard.spec.ts new file mode 100644 index 00000000..23c19777 --- /dev/null +++ b/e2e/whiteboard.spec.ts @@ -0,0 +1,256 @@ +import { expect, test } from "@playwright/test"; +import { cleanupE2EAgents, createAgentViaAPI } from "./helpers"; + +const authHeader = { + Authorization: `Bearer ${process.env.AUTH_TOKEN ?? "dev-token"}`, +}; + +async function waitForWhiteboard( + page: import("@playwright/test").Page +): Promise { + await page.getByTestId("whiteboard-canvas").waitFor({ state: "visible" }); + // Excalidraw mounts its own canvases inside the host div. + await page + .locator('[data-testid="whiteboard-canvas"] canvas') + .first() + .waitFor({ state: "visible" }); +} + +test.describe("Whiteboard tab", () => { + test.afterEach(async ({ request }) => { + await cleanupE2EAgents(request); + }); + + test("draws a rectangle that persists across reloads", async ({ + page, + request, + }) => { + const agent = await createAgentViaAPI(request, { + name: `e2e-whiteboard-${Date.now()}`, + }); + + await page.goto(`/agents/${agent.id}/whiteboard`, { + waitUntil: "domcontentloaded", + }); + await page.getByTestId("agent-sidebar").waitFor({ state: "visible" }); + await expect(page.getByTestId("center-tab-whiteboard")).toHaveAttribute( + "aria-selected", + "true" + ); + await waitForWhiteboard(page); + + // Focus the canvas first — Excalidraw shortcuts only register once the + // canvas has been clicked. Draw in the right half: with a shape tool + // active, Excalidraw overlays a properties panel on the left side. + const box = await page.getByTestId("whiteboard-canvas").boundingBox(); + if (!box) throw new Error("whiteboard canvas has no bounding box"); + const cx = box.x + box.width * 0.7; + const cy = box.y + box.height * 0.6; + await page.mouse.click(cx, cy); + await page.keyboard.press("r"); + await page.mouse.move(cx, cy); + await page.mouse.down(); + await page.mouse.move(cx + 160, cy + 90, { steps: 8 }); + await page.mouse.up(); + + // Debounced save is 1s; poll the API until the scene lands. + await expect + .poll( + async () => { + const res = await request.get( + `/api/v1/agents/${agent.id}/whiteboard`, + { headers: authHeader } + ); + const body = (await res.json()) as { + version: number; + scene: { elements: Array<{ type: string }> }; + }; + return body.scene.elements.filter((e) => e.type === "rectangle") + .length; + }, + { timeout: 10_000 } + ) + .toBeGreaterThan(0); + + // Reload and confirm the board comes back from the server. + await page.reload({ waitUntil: "domcontentloaded" }); + await waitForWhiteboard(page); + const res = await request.get(`/api/v1/agents/${agent.id}/whiteboard`, { + headers: authHeader, + }); + const body = (await res.json()) as { + version: number; + scene: { elements: unknown[] }; + }; + expect(body.version).toBeGreaterThanOrEqual(1); + expect(body.scene.elements.length).toBeGreaterThan(0); + }); + + test("tab bar switches between terminal and whiteboard without unmounting the board", async ({ + page, + request, + }) => { + const agent = await createAgentViaAPI(request, { + name: `e2e-whiteboard-tabs-${Date.now()}`, + }); + + await page.goto(`/agents/${agent.id}`, { waitUntil: "domcontentloaded" }); + await page.getByTestId("terminal-pane").waitFor({ state: "visible" }); + + await page.getByTestId("center-tab-whiteboard").click(); + await expect(page).toHaveURL(new RegExp(`/agents/${agent.id}/whiteboard$`)); + await waitForWhiteboard(page); + + // Switch back to terminal: whiteboard stays mounted (hidden), so undo + // history and zoom survive tab flips. + await page.getByTestId("center-tab-terminal").click(); + await expect(page).toHaveURL(new RegExp(`/agents/${agent.id}$`)); + await expect(page.getByTestId("whiteboard-canvas")).toBeHidden(); + await expect(page.getByTestId("whiteboard-canvas")).toBeAttached(); + + await page.getByTestId("center-tab-whiteboard").click(); + await expect(page.getByTestId("whiteboard-canvas")).toBeVisible(); + }); + + test("agent whiteboard_update lands on the board and lights the tab dot", async ({ + page, + request, + }) => { + const agent = await createAgentViaAPI(request, { + name: `e2e-whiteboard-agent-${Date.now()}`, + }); + + // Mount the whiteboard once, then watch from the terminal tab. + await page.goto(`/agents/${agent.id}/whiteboard`, { + waitUntil: "domcontentloaded", + }); + await waitForWhiteboard(page); + await page.getByTestId("center-tab-terminal").click(); + await expect(page).toHaveURL(new RegExp(`/agents/${agent.id}$`)); + + // Draw through the real MCP tool, exactly as an agent would. The scoped + // MCP route accepts tokenless local calls; the server bearer token is + // NOT a valid agent-scoped MCP token and would 403. + const mcpRes = await request.post(`/api/mcp/${agent.id}`, { + headers: { + accept: "application/json, text/event-stream", + }, + data: { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "whiteboard_update", + arguments: { + ops: [ + { + op: "add", + type: "rect", + id: "api", + x: 100, + y: 100, + w: 160, + h: 70, + label: "api", + }, + { + op: "add", + type: "ellipse", + id: "db", + x: 400, + y: 300, + w: 140, + h: 80, + label: "db", + fill: "violet", + }, + { + op: "add", + type: "arrow", + id: "flow", + from: "api", + to: "db", + label: "reads", + elbow: true, + }, + { + op: "add", + type: "arrow", + id: "retry", + from: "db", + to: "api", + style: "dashed", + startHead: "dot", + endHead: "triangle", + via: [[150, 400]], + }, + ], + }, + }, + }, + }); + expect(mcpRes.ok()).toBe(true); + const dataLine = (await mcpRes.text()) + .split("\n") + .find((l) => l.startsWith("data: ")); + expect(dataLine).toBeDefined(); + const rpc = JSON.parse(dataLine!.slice(6)) as { + result: { + structuredContent: { + ok: boolean; + created: Array<{ id: string }>; + errors: string[]; + }; + }; + }; + expect(rpc.result.structuredContent.ok).toBe(true); + expect(rpc.result.structuredContent.created.map((c) => c.id)).toEqual([ + "api", + "db", + "flow", + "retry", + ]); + + // The SSE event lights the "agent drew" dot while we're on Terminal… + await expect(page.getByTestId("whiteboard-agent-drew-dot")).toBeVisible({ + timeout: 10_000, + }); + + // …and the scene now holds the bound diagram (shapes + labels + arrow). + const res = await request.get(`/api/v1/agents/${agent.id}/whiteboard`, { + headers: authHeader, + }); + const body = (await res.json()) as { + scene: { + elements: Array<{ + id: string; + type: string; + points?: number[][]; + strokeStyle?: string; + backgroundColor?: string; + startArrowhead?: string | null; + endArrowhead?: string | null; + startBinding?: { elementId: string } | null; + endBinding?: { elementId: string } | null; + }>; + }; + }; + const arrow = body.scene.elements.find((e) => e.id === "flow"); + expect(arrow?.startBinding?.elementId).toBe("api"); + expect(arrow?.endBinding?.elementId).toBe("db"); + // elbow: true routes with right-angle bends, not a single segment. + expect(arrow?.points?.length).toBeGreaterThan(2); + const retry = body.scene.elements.find((e) => e.id === "retry"); + expect(retry?.strokeStyle).toBe("dashed"); + expect(retry?.startArrowhead).toBe("dot"); + expect(retry?.endArrowhead).toBe("triangle"); + expect(retry?.points?.length).toBe(3); // via bend baked in + const db = body.scene.elements.find((e) => e.id === "db"); + expect(db?.backgroundColor).toBe("#e5dbff"); + expect(body.scene.elements.filter((e) => e.type === "text").length).toBe(3); + + // Visiting the whiteboard clears the dot. + await page.getByTestId("center-tab-whiteboard").click(); + await expect(page.getByTestId("whiteboard-agent-drew-dot")).toHaveCount(0); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 6dae4fa9..4e08bf2d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -41,6 +41,15 @@ export default defineConfig({ screenshot: "only-on-failure", trace: "retain-on-failure", extraHTTPHeaders: {}, + // Escape hatch for machines without Playwright's downloaded browsers + // (e.g. a Homebrew Chromium install). + ...(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + ? { + launchOptions: { + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + }, + } + : {}), }, webServer: { command: process.env.E2E_SKIP_WEB_BUILD diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55af6f2c..34761d96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,16 +85,22 @@ importers: version: 8.18.1 "@vitest/coverage-v8": specifier: 4.1.2 - version: 4.1.2(vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 4.1.2(vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.23.0)(yaml@2.8.3))) + tsx: + specifier: ^4.23.0 + version: 4.23.0 vite: specifier: ^6.0.0 - version: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.23.0)(yaml@2.8.3) vitest: specifier: ^4.0.18 - version: 4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.23.0)(yaml@2.8.3)) apps/web: dependencies: + "@excalidraw/excalidraw": + specifier: 0.18.1 + version: 0.18.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) "@radix-ui/react-checkbox": specifier: ^1.3.3 version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -206,7 +212,7 @@ importers: version: 9.39.4 "@tailwindcss/typography": specifier: ^0.5.19 - version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) + version: 0.5.19(tailwindcss@3.4.19(tsx@4.23.0)(yaml@2.8.3)) "@testing-library/react": specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -218,10 +224,10 @@ importers: version: 18.3.7(@types/react@18.3.28) "@vitejs/plugin-react": specifier: ^4.7.0 - version: 4.7.0(vite@5.4.21(@types/node@24.12.0)(terser@5.46.1)) + version: 4.7.0(vite@5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1)) "@vitest/coverage-v8": specifier: 2.1.9 - version: 2.1.9(vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(terser@5.46.1)) + version: 2.1.9(vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(sass@1.51.0)(terser@5.46.1)) autoprefixer: specifier: ^10.4.20 version: 10.4.27(postcss@8.5.8) @@ -248,19 +254,19 @@ importers: version: 8.5.8 tailwindcss: specifier: ^3.4.15 - version: 3.4.19(tsx@4.21.0)(yaml@2.8.3) + version: 3.4.19(tsx@4.23.0)(yaml@2.8.3) typescript-eslint: specifier: ^8.56.1 version: 8.57.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) vite: specifier: ^5.4.21 - version: 5.4.21(@types/node@24.12.0)(terser@5.46.1) + version: 5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1) vite-plugin-pwa: specifier: ^1.2.0 - version: 1.2.0(vite@5.4.21(@types/node@24.12.0)(terser@5.46.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) + version: 1.2.0(vite@5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(terser@5.46.1) + version: 2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(sass@1.51.0)(terser@5.46.1) packages: "@alloc/quick-lru@5.2.0": @@ -491,14 +497,6 @@ packages: } engines: { node: ">=6.9.0" } - "@babel/parser@7.29.2": - resolution: - { - integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==, - } - engines: { node: ">=6.0.0" } - hasBin: true - "@babel/parser@7.29.3": resolution: { @@ -1123,6 +1121,12 @@ packages: } engines: { node: ">=18" } + "@braintree/sanitize-url@6.0.2": + resolution: + { + integrity: sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==, + } + "@braintree/sanitize-url@7.1.2": resolution: { @@ -1136,12 +1140,42 @@ packages: } hasBin: true + "@chevrotain/cst-dts-gen@11.0.3": + resolution: + { + integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==, + } + + "@chevrotain/gast@11.0.3": + resolution: + { + integrity: sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==, + } + + "@chevrotain/regexp-to-ast@11.0.3": + resolution: + { + integrity: sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==, + } + + "@chevrotain/types@11.0.3": + resolution: + { + integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==, + } + "@chevrotain/types@11.1.2": resolution: { integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==, } + "@chevrotain/utils@11.0.3": + resolution: + { + integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==, + } + "@csstools/color-helpers@6.0.2": resolution: { @@ -1226,10 +1260,10 @@ packages: cpu: [ppc64] os: [aix] - "@esbuild/aix-ppc64@0.27.4": + "@esbuild/aix-ppc64@0.28.1": resolution: { - integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==, + integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==, } engines: { node: ">=18" } cpu: [ppc64] @@ -1253,10 +1287,10 @@ packages: cpu: [arm64] os: [android] - "@esbuild/android-arm64@0.27.4": + "@esbuild/android-arm64@0.28.1": resolution: { - integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==, + integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==, } engines: { node: ">=18" } cpu: [arm64] @@ -1280,10 +1314,10 @@ packages: cpu: [arm] os: [android] - "@esbuild/android-arm@0.27.4": + "@esbuild/android-arm@0.28.1": resolution: { - integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==, + integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==, } engines: { node: ">=18" } cpu: [arm] @@ -1307,10 +1341,10 @@ packages: cpu: [x64] os: [android] - "@esbuild/android-x64@0.27.4": + "@esbuild/android-x64@0.28.1": resolution: { - integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==, + integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==, } engines: { node: ">=18" } cpu: [x64] @@ -1334,10 +1368,10 @@ packages: cpu: [arm64] os: [darwin] - "@esbuild/darwin-arm64@0.27.4": + "@esbuild/darwin-arm64@0.28.1": resolution: { - integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==, + integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==, } engines: { node: ">=18" } cpu: [arm64] @@ -1361,10 +1395,10 @@ packages: cpu: [x64] os: [darwin] - "@esbuild/darwin-x64@0.27.4": + "@esbuild/darwin-x64@0.28.1": resolution: { - integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==, + integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==, } engines: { node: ">=18" } cpu: [x64] @@ -1388,10 +1422,10 @@ packages: cpu: [arm64] os: [freebsd] - "@esbuild/freebsd-arm64@0.27.4": + "@esbuild/freebsd-arm64@0.28.1": resolution: { - integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==, + integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==, } engines: { node: ">=18" } cpu: [arm64] @@ -1415,10 +1449,10 @@ packages: cpu: [x64] os: [freebsd] - "@esbuild/freebsd-x64@0.27.4": + "@esbuild/freebsd-x64@0.28.1": resolution: { - integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==, + integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==, } engines: { node: ">=18" } cpu: [x64] @@ -1442,10 +1476,10 @@ packages: cpu: [arm64] os: [linux] - "@esbuild/linux-arm64@0.27.4": + "@esbuild/linux-arm64@0.28.1": resolution: { - integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==, + integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==, } engines: { node: ">=18" } cpu: [arm64] @@ -1469,10 +1503,10 @@ packages: cpu: [arm] os: [linux] - "@esbuild/linux-arm@0.27.4": + "@esbuild/linux-arm@0.28.1": resolution: { - integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==, + integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==, } engines: { node: ">=18" } cpu: [arm] @@ -1496,10 +1530,10 @@ packages: cpu: [ia32] os: [linux] - "@esbuild/linux-ia32@0.27.4": + "@esbuild/linux-ia32@0.28.1": resolution: { - integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==, + integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==, } engines: { node: ">=18" } cpu: [ia32] @@ -1523,10 +1557,10 @@ packages: cpu: [loong64] os: [linux] - "@esbuild/linux-loong64@0.27.4": + "@esbuild/linux-loong64@0.28.1": resolution: { - integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==, + integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==, } engines: { node: ">=18" } cpu: [loong64] @@ -1550,10 +1584,10 @@ packages: cpu: [mips64el] os: [linux] - "@esbuild/linux-mips64el@0.27.4": + "@esbuild/linux-mips64el@0.28.1": resolution: { - integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==, + integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==, } engines: { node: ">=18" } cpu: [mips64el] @@ -1577,10 +1611,10 @@ packages: cpu: [ppc64] os: [linux] - "@esbuild/linux-ppc64@0.27.4": + "@esbuild/linux-ppc64@0.28.1": resolution: { - integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==, + integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==, } engines: { node: ">=18" } cpu: [ppc64] @@ -1604,10 +1638,10 @@ packages: cpu: [riscv64] os: [linux] - "@esbuild/linux-riscv64@0.27.4": + "@esbuild/linux-riscv64@0.28.1": resolution: { - integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==, + integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==, } engines: { node: ">=18" } cpu: [riscv64] @@ -1631,10 +1665,10 @@ packages: cpu: [s390x] os: [linux] - "@esbuild/linux-s390x@0.27.4": + "@esbuild/linux-s390x@0.28.1": resolution: { - integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==, + integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==, } engines: { node: ">=18" } cpu: [s390x] @@ -1658,10 +1692,10 @@ packages: cpu: [x64] os: [linux] - "@esbuild/linux-x64@0.27.4": + "@esbuild/linux-x64@0.28.1": resolution: { - integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==, + integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==, } engines: { node: ">=18" } cpu: [x64] @@ -1676,10 +1710,10 @@ packages: cpu: [arm64] os: [netbsd] - "@esbuild/netbsd-arm64@0.27.4": + "@esbuild/netbsd-arm64@0.28.1": resolution: { - integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==, + integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==, } engines: { node: ">=18" } cpu: [arm64] @@ -1703,10 +1737,10 @@ packages: cpu: [x64] os: [netbsd] - "@esbuild/netbsd-x64@0.27.4": + "@esbuild/netbsd-x64@0.28.1": resolution: { - integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==, + integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==, } engines: { node: ">=18" } cpu: [x64] @@ -1721,10 +1755,10 @@ packages: cpu: [arm64] os: [openbsd] - "@esbuild/openbsd-arm64@0.27.4": + "@esbuild/openbsd-arm64@0.28.1": resolution: { - integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==, + integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==, } engines: { node: ">=18" } cpu: [arm64] @@ -1748,10 +1782,10 @@ packages: cpu: [x64] os: [openbsd] - "@esbuild/openbsd-x64@0.27.4": + "@esbuild/openbsd-x64@0.28.1": resolution: { - integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==, + integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==, } engines: { node: ">=18" } cpu: [x64] @@ -1766,10 +1800,10 @@ packages: cpu: [arm64] os: [openharmony] - "@esbuild/openharmony-arm64@0.27.4": + "@esbuild/openharmony-arm64@0.28.1": resolution: { - integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==, + integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==, } engines: { node: ">=18" } cpu: [arm64] @@ -1793,10 +1827,10 @@ packages: cpu: [x64] os: [sunos] - "@esbuild/sunos-x64@0.27.4": + "@esbuild/sunos-x64@0.28.1": resolution: { - integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==, + integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==, } engines: { node: ">=18" } cpu: [x64] @@ -1820,10 +1854,10 @@ packages: cpu: [arm64] os: [win32] - "@esbuild/win32-arm64@0.27.4": + "@esbuild/win32-arm64@0.28.1": resolution: { - integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==, + integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==, } engines: { node: ">=18" } cpu: [arm64] @@ -1847,10 +1881,10 @@ packages: cpu: [ia32] os: [win32] - "@esbuild/win32-ia32@0.27.4": + "@esbuild/win32-ia32@0.28.1": resolution: { - integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==, + integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==, } engines: { node: ">=18" } cpu: [ia32] @@ -1874,10 +1908,10 @@ packages: cpu: [x64] os: [win32] - "@esbuild/win32-x64@0.27.4": + "@esbuild/win32-x64@0.28.1": resolution: { - integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==, + integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==, } engines: { node: ">=18" } cpu: [x64] @@ -1948,6 +1982,40 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + "@excalidraw/excalidraw@0.18.1": + resolution: + { + integrity: sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw==, + } + peerDependencies: + react: ^17.0.2 || ^18.2.0 || ^19.0.0 + react-dom: ^17.0.2 || ^18.2.0 || ^19.0.0 + + "@excalidraw/laser-pointer@1.3.1": + resolution: + { + integrity: sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==, + } + + "@excalidraw/markdown-to-text@0.1.2": + resolution: + { + integrity: sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==, + } + + "@excalidraw/mermaid-to-excalidraw@2.2.2": + resolution: + { + integrity: sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg==, + } + + "@excalidraw/random-username@1.1.0": + resolution: + { + integrity: sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==, + } + engines: { node: ">=10" } + "@exodus/bytes@1.15.0": resolution: { @@ -2156,6 +2224,7 @@ packages: } cpu: [arm64] os: [linux] + libc: [glibc] "@img/sharp-libvips-linux-arm@1.2.4": resolution: @@ -2164,6 +2233,7 @@ packages: } cpu: [arm] os: [linux] + libc: [glibc] "@img/sharp-libvips-linux-ppc64@1.2.4": resolution: @@ -2172,6 +2242,7 @@ packages: } cpu: [ppc64] os: [linux] + libc: [glibc] "@img/sharp-libvips-linux-riscv64@1.2.4": resolution: @@ -2180,6 +2251,7 @@ packages: } cpu: [riscv64] os: [linux] + libc: [glibc] "@img/sharp-libvips-linux-s390x@1.2.4": resolution: @@ -2188,6 +2260,7 @@ packages: } cpu: [s390x] os: [linux] + libc: [glibc] "@img/sharp-libvips-linux-x64@1.2.4": resolution: @@ -2196,6 +2269,7 @@ packages: } cpu: [x64] os: [linux] + libc: [glibc] "@img/sharp-libvips-linuxmusl-arm64@1.2.4": resolution: @@ -2204,6 +2278,7 @@ packages: } cpu: [arm64] os: [linux] + libc: [musl] "@img/sharp-libvips-linuxmusl-x64@1.2.4": resolution: @@ -2212,6 +2287,7 @@ packages: } cpu: [x64] os: [linux] + libc: [musl] "@img/sharp-linux-arm64@0.34.5": resolution: @@ -2221,6 +2297,7 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [arm64] os: [linux] + libc: [glibc] "@img/sharp-linux-arm@0.34.5": resolution: @@ -2230,6 +2307,7 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [arm] os: [linux] + libc: [glibc] "@img/sharp-linux-ppc64@0.34.5": resolution: @@ -2239,6 +2317,7 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [ppc64] os: [linux] + libc: [glibc] "@img/sharp-linux-riscv64@0.34.5": resolution: @@ -2248,6 +2327,7 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [riscv64] os: [linux] + libc: [glibc] "@img/sharp-linux-s390x@0.34.5": resolution: @@ -2257,6 +2337,7 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [s390x] os: [linux] + libc: [glibc] "@img/sharp-linux-x64@0.34.5": resolution: @@ -2266,6 +2347,7 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [x64] os: [linux] + libc: [glibc] "@img/sharp-linuxmusl-arm64@0.34.5": resolution: @@ -2275,6 +2357,7 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [arm64] os: [linux] + libc: [musl] "@img/sharp-linuxmusl-x64@0.34.5": resolution: @@ -2284,6 +2367,7 @@ packages: engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } cpu: [x64] os: [linux] + libc: [musl] "@img/sharp-wasm32@0.34.5": resolution: @@ -2385,6 +2469,12 @@ packages: } engines: { node: ">=8" } + "@mermaid-js/parser@0.6.3": + resolution: + { + integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==, + } + "@mermaid-js/parser@1.1.1": resolution: { @@ -2452,12 +2542,40 @@ packages: integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==, } + "@radix-ui/primitive@1.0.0": + resolution: + { + integrity: sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==, + } + + "@radix-ui/primitive@1.1.1": + resolution: + { + integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==, + } + "@radix-ui/primitive@1.1.3": resolution: { integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==, } + "@radix-ui/react-arrow@1.1.2": + resolution: + { + integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + "@radix-ui/react-arrow@1.1.7": resolution: { @@ -2490,6 +2608,15 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-collection@1.0.1": + resolution: + { + integrity: sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + "@radix-ui/react-collection@1.1.7": resolution: { @@ -2506,6 +2633,26 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-compose-refs@1.0.0": + resolution: + { + integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-compose-refs@1.1.1": + resolution: + { + integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@radix-ui/react-compose-refs@1.1.2": resolution: { @@ -2518,6 +2665,26 @@ packages: "@types/react": optional: true + "@radix-ui/react-context@1.0.0": + resolution: + { + integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-context@1.1.1": + resolution: + { + integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@radix-ui/react-context@1.1.2": resolution: { @@ -2546,6 +2713,14 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-direction@1.0.0": + resolution: + { + integrity: sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + "@radix-ui/react-direction@1.1.1": resolution: { @@ -2574,6 +2749,22 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-dismissable-layer@1.1.5": + resolution: + { + integrity: sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + "@radix-ui/react-dropdown-menu@2.1.16": resolution: { @@ -2590,6 +2781,18 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-focus-guards@1.1.1": + resolution: + { + integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@radix-ui/react-focus-guards@1.1.3": resolution: { @@ -2602,6 +2805,22 @@ packages: "@types/react": optional: true + "@radix-ui/react-focus-scope@1.1.2": + resolution: + { + integrity: sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + "@radix-ui/react-focus-scope@1.1.7": resolution: { @@ -2618,6 +2837,26 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-id@1.0.0": + resolution: + { + integrity: sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-id@1.1.0": + resolution: + { + integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@radix-ui/react-id@1.1.1": resolution: { @@ -2662,6 +2901,38 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-popover@1.1.6": + resolution: + { + integrity: sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + + "@radix-ui/react-popper@1.2.2": + resolution: + { + integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + "@radix-ui/react-popper@1.2.8": resolution: { @@ -2678,6 +2949,22 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-portal@1.1.4": + resolution: + { + integrity: sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + "@radix-ui/react-portal@1.1.9": resolution: { @@ -2694,6 +2981,31 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-presence@1.0.0": + resolution: + { + integrity: sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-presence@1.1.2": + resolution: + { + integrity: sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + "@radix-ui/react-presence@1.1.5": resolution: { @@ -2710,6 +3022,31 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-primitive@1.0.1": + resolution: + { + integrity: sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-primitive@2.0.2": + resolution: + { + integrity: sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==, + } + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + "@radix-ui/react-primitive@2.1.3": resolution: { @@ -2742,6 +3079,15 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-roving-focus@1.0.2": + resolution: + { + integrity: sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + "@radix-ui/react-roving-focus@1.1.11": resolution: { @@ -2790,6 +3136,26 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-slot@1.0.1": + resolution: + { + integrity: sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-slot@1.1.2": + resolution: + { + integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@radix-ui/react-slot@1.2.3": resolution: { @@ -2814,6 +3180,15 @@ packages: "@types/react": optional: true + "@radix-ui/react-tabs@1.0.2": + resolution: + { + integrity: sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + "@radix-ui/react-tooltip@1.2.8": resolution: { @@ -2830,6 +3205,26 @@ packages: "@types/react-dom": optional: true + "@radix-ui/react-use-callback-ref@1.0.0": + resolution: + { + integrity: sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-use-callback-ref@1.1.0": + resolution: + { + integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@radix-ui/react-use-callback-ref@1.1.1": resolution: { @@ -2842,6 +3237,26 @@ packages: "@types/react": optional: true + "@radix-ui/react-use-controllable-state@1.0.0": + resolution: + { + integrity: sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-use-controllable-state@1.1.0": + resolution: + { + integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@radix-ui/react-use-controllable-state@1.2.2": resolution: { @@ -2866,6 +3281,18 @@ packages: "@types/react": optional: true + "@radix-ui/react-use-escape-keydown@1.1.0": + resolution: + { + integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@radix-ui/react-use-escape-keydown@1.1.1": resolution: { @@ -2878,10 +3305,54 @@ packages: "@types/react": optional: true + "@radix-ui/react-use-layout-effect@1.0.0": + resolution: + { + integrity: sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==, + } + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + "@radix-ui/react-use-layout-effect@1.1.0": + resolution: + { + integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@radix-ui/react-use-layout-effect@1.1.1": resolution: { - integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==, + integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + + "@radix-ui/react-use-previous@1.1.1": + resolution: + { + integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==, + } + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + + "@radix-ui/react-use-rect@1.1.0": + resolution: + { + integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==, } peerDependencies: "@types/react": "*" @@ -2890,10 +3361,10 @@ packages: "@types/react": optional: true - "@radix-ui/react-use-previous@1.1.1": + "@radix-ui/react-use-rect@1.1.1": resolution: { - integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==, + integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==, } peerDependencies: "@types/react": "*" @@ -2902,10 +3373,10 @@ packages: "@types/react": optional: true - "@radix-ui/react-use-rect@1.1.1": + "@radix-ui/react-use-size@1.1.0": resolution: { - integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==, + integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==, } peerDependencies: "@types/react": "*" @@ -2942,6 +3413,12 @@ packages: "@types/react-dom": optional: true + "@radix-ui/rect@1.1.0": + resolution: + { + integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==, + } + "@radix-ui/rect@1.1.1": resolution: { @@ -3090,6 +3567,7 @@ packages: } cpu: [arm] os: [linux] + libc: [glibc] "@rollup/rollup-linux-arm-musleabihf@4.60.1": resolution: @@ -3098,6 +3576,7 @@ packages: } cpu: [arm] os: [linux] + libc: [musl] "@rollup/rollup-linux-arm64-gnu@4.60.1": resolution: @@ -3106,6 +3585,7 @@ packages: } cpu: [arm64] os: [linux] + libc: [glibc] "@rollup/rollup-linux-arm64-musl@4.60.1": resolution: @@ -3114,6 +3594,7 @@ packages: } cpu: [arm64] os: [linux] + libc: [musl] "@rollup/rollup-linux-loong64-gnu@4.60.1": resolution: @@ -3122,6 +3603,7 @@ packages: } cpu: [loong64] os: [linux] + libc: [glibc] "@rollup/rollup-linux-loong64-musl@4.60.1": resolution: @@ -3130,6 +3612,7 @@ packages: } cpu: [loong64] os: [linux] + libc: [musl] "@rollup/rollup-linux-ppc64-gnu@4.60.1": resolution: @@ -3138,6 +3621,7 @@ packages: } cpu: [ppc64] os: [linux] + libc: [glibc] "@rollup/rollup-linux-ppc64-musl@4.60.1": resolution: @@ -3146,6 +3630,7 @@ packages: } cpu: [ppc64] os: [linux] + libc: [musl] "@rollup/rollup-linux-riscv64-gnu@4.60.1": resolution: @@ -3154,6 +3639,7 @@ packages: } cpu: [riscv64] os: [linux] + libc: [glibc] "@rollup/rollup-linux-riscv64-musl@4.60.1": resolution: @@ -3162,6 +3648,7 @@ packages: } cpu: [riscv64] os: [linux] + libc: [musl] "@rollup/rollup-linux-s390x-gnu@4.60.1": resolution: @@ -3170,6 +3657,7 @@ packages: } cpu: [s390x] os: [linux] + libc: [glibc] "@rollup/rollup-linux-x64-gnu@4.60.1": resolution: @@ -3178,6 +3666,7 @@ packages: } cpu: [x64] os: [linux] + libc: [glibc] "@rollup/rollup-linux-x64-musl@4.60.1": resolution: @@ -3186,6 +3675,7 @@ packages: } cpu: [x64] os: [linux] + libc: [musl] "@rollup/rollup-openbsd-x64@4.60.1": resolution: @@ -3775,6 +4265,7 @@ packages: { integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==, } + deprecated: Potential CWE-502 - Update to 1.3.1 or higher "@upsetjs/venn.js@2.0.0": resolution: @@ -4287,6 +4778,12 @@ packages: } engines: { node: ">=8" } + browser-fs-access@0.29.1: + resolution: + { + integrity: sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==, + } + browserslist@4.28.1: resolution: { @@ -4356,6 +4853,12 @@ packages: integrity: sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==, } + canvas-roundrect-polyfill@0.0.1: + resolution: + { + integrity: sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==, + } + ccount@2.0.1: resolution: { @@ -4414,6 +4917,20 @@ packages: } engines: { node: ">= 16" } + chevrotain-allstar@0.3.1: + resolution: + { + integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==, + } + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.0.3: + resolution: + { + integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==, + } + chokidar@3.6.0: resolution: { @@ -4454,6 +4971,13 @@ packages: } engines: { node: ">=12" } + clsx@1.1.1: + resolution: + { + integrity: sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==, + } + engines: { node: ">=6" } + clsx@2.1.1: resolution: { @@ -4608,6 +5132,13 @@ packages: integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==, } + crc-32@0.3.0: + resolution: + { + integrity: sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==, + } + engines: { node: ">=0.8" } + croner@10.0.1: resolution: { @@ -4622,6 +5153,14 @@ packages: } hasBin: true + cross-env@7.0.3: + resolution: + { + integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==, + } + engines: { node: ">=10.14", npm: ">=6", yarn: ">=1" } + hasBin: true + cross-spawn@7.0.6: resolution: { @@ -5281,6 +5820,13 @@ packages: integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==, } + es6-promise-pool@2.5.0: + resolution: + { + integrity: sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==, + } + engines: { node: ">=0.10.0" } + esbuild@0.21.5: resolution: { @@ -5297,10 +5843,10 @@ packages: engines: { node: ">=18" } hasBin: true - esbuild@0.27.4: + esbuild@0.28.1: resolution: { - integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==, + integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==, } engines: { node: ">=18" } hasBin: true @@ -5674,6 +6220,13 @@ packages: integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==, } + fractional-indexing@3.2.0: + resolution: + { + integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==, + } + engines: { node: ^14.13.1 || >=16.0.0 } + framer-motion@12.38.0: resolution: { @@ -5740,6 +6293,13 @@ packages: integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==, } + fuzzy@0.1.3: + resolution: + { + integrity: sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==, + } + engines: { node: ">= 0.6.0" } + generator-function@2.0.1: resolution: { @@ -5802,12 +6362,6 @@ packages: } engines: { node: ">= 0.4" } - get-tsconfig@4.13.7: - resolution: - { - integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==, - } - gitdiff-parser@0.3.1: resolution: { @@ -5866,6 +6420,12 @@ packages: } engines: { node: ">= 0.4" } + glur@1.1.2: + resolution: + { + integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==, + } + gopd@1.2.0: resolution: { @@ -6046,6 +6606,12 @@ packages: } engines: { node: ">= 4" } + image-blob-reduce@3.0.1: + resolution: + { + integrity: sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==, + } + immer@10.2.0: resolution: { @@ -6058,6 +6624,12 @@ packages: integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==, } + immutable@4.3.9: + resolution: + { + integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==, + } + import-fresh@3.3.1: resolution: { @@ -6478,6 +7050,30 @@ packages: integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==, } + jotai-scope@0.7.2: + resolution: + { + integrity: sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==, + } + peerDependencies: + jotai: ">=2.9.2" + react: ">=17.0.0" + + jotai@2.11.0: + resolution: + { + integrity: sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==, + } + engines: { node: ">=12.20.0" } + peerDependencies: + "@types/react": ">=17.0.0" + react: ">=17.0.0" + peerDependenciesMeta: + "@types/react": + optional: true + react: + optional: true + jotai@2.19.0: resolution: { @@ -6627,6 +7223,13 @@ packages: integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==, } + langium@3.3.1: + resolution: + { + integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==, + } + engines: { node: ">=16.0.0" } + layout-base@1.0.2: resolution: { @@ -6694,6 +7297,12 @@ packages: } engines: { node: ">=10" } + lodash-es@4.17.21: + resolution: + { + integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==, + } + lodash-es@4.18.1: resolution: { @@ -6718,6 +7327,12 @@ packages: integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==, } + lodash.throttle@4.1.1: + resolution: + { + integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==, + } + lodash@4.17.23: resolution: { @@ -7214,6 +7829,12 @@ packages: integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, } + multimath@2.0.0: + resolution: + { + integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==, + } + mz@2.7.0: resolution: { @@ -7228,6 +7849,22 @@ packages: engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } hasBin: true + nanoid@3.3.3: + resolution: + { + integrity: sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==, + } + engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + hasBin: true + + nanoid@4.0.2: + resolution: + { + integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==, + } + engines: { node: ^14 || ^16 || >=18 } + hasBin: true + natural-compare@1.4.0: resolution: { @@ -7364,6 +8001,12 @@ packages: } engines: { node: ">=18" } + open-color@1.9.1: + resolution: + { + integrity: sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==, + } + optionator@0.9.4: resolution: { @@ -7404,6 +8047,12 @@ packages: integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==, } + pako@2.0.3: + resolution: + { + integrity: sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==, + } + parent-module@1.0.1: resolution: { @@ -7495,6 +8144,12 @@ packages: } engines: { node: ">= 14.16" } + perfect-freehand@1.2.0: + resolution: + { + integrity: sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==, + } + pg-cloudflare@1.3.0: resolution: { @@ -7553,6 +8208,12 @@ packages: integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==, } + pica@7.1.1: + resolution: + { + integrity: sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==, + } + picocolors@1.1.1: resolution: { @@ -7629,12 +8290,36 @@ packages: engines: { node: ">=18" } hasBin: true + png-chunk-text@1.0.0: + resolution: + { + integrity: sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw==, + } + + png-chunks-encode@1.0.0: + resolution: + { + integrity: sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA==, + } + + png-chunks-extract@1.0.0: + resolution: + { + integrity: sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==, + } + points-on-curve@0.2.0: resolution: { integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==, } + points-on-curve@1.0.1: + resolution: + { + integrity: sha512-3nmX4/LIiyuwGLwuUrfhTlDeQFlAhi7lyK/zcRNGhalwapDWgAGR82bUpmn2mA03vII3fvNCG8jAONzKXwpxAg==, + } + points-on-path@0.2.1: resolution: { @@ -7825,6 +8510,12 @@ packages: } engines: { node: ">=6" } + pwacompat@2.0.17: + resolution: + { + integrity: sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==, + } + qs@6.15.0: resolution: { @@ -8167,12 +8858,6 @@ packages: } engines: { node: ">=4" } - resolve-pkg-maps@1.0.0: - resolution: - { - integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==, - } - resolve@1.22.11: resolution: { @@ -8238,6 +8923,12 @@ packages: engines: { node: ">=18.0.0", npm: ">=8.0.0" } hasBin: true + roughjs@4.6.4: + resolution: + { + integrity: sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw==, + } + roughjs@4.6.6: resolution: { @@ -8310,6 +9001,14 @@ packages: integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, } + sass@1.51.0: + resolution: + { + integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==, + } + engines: { node: ">=12.0.0" } + hasBin: true + saxes@6.0.0: resolution: { @@ -8486,6 +9185,13 @@ packages: } engines: { node: ">=20" } + sliced@1.0.1: + resolution: + { + integrity: sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==, + } + deprecated: Unsupported + smob@1.6.1: resolution: { @@ -8972,14 +9678,20 @@ packages: integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, } - tsx@4.21.0: + tsx@4.23.0: resolution: { - integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==, + integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==, } engines: { node: ">=18.0.0" } hasBin: true + tunnel-rat@0.1.2: + resolution: + { + integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==, + } + type-check@0.4.0: resolution: { @@ -9418,6 +10130,44 @@ packages: jsdom: optional: true + vscode-jsonrpc@8.2.0: + resolution: + { + integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==, + } + engines: { node: ">=14.0.0" } + + vscode-languageserver-protocol@3.17.5: + resolution: + { + integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==, + } + + vscode-languageserver-textdocument@1.0.12: + resolution: + { + integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==, + } + + vscode-languageserver-types@3.17.5: + resolution: + { + integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==, + } + + vscode-languageserver@9.0.1: + resolution: + { + integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==, + } + hasBin: true + + vscode-uri@3.0.8: + resolution: + { + integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==, + } + w3c-xmlserializer@5.0.0: resolution: { @@ -9444,6 +10194,12 @@ packages: } engines: { node: ">=20" } + webworkify@1.5.0: + resolution: + { + integrity: sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==, + } + whatwg-mimetype@3.0.0: resolution: { @@ -9737,6 +10493,24 @@ packages: integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==, } + zustand@4.5.7: + resolution: + { + integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==, + } + engines: { node: ">=12.7.0" } + peerDependencies: + "@types/react": ">=16.8" + immer: ">=9.0.6" + react: ">=16.8" + peerDependenciesMeta: + "@types/react": + optional: true + immer: + optional: true + react: + optional: true + zwitch@2.0.4: resolution: { @@ -9797,7 +10571,7 @@ snapshots: "@babel/helper-compilation-targets": 7.28.6 "@babel/helper-module-transforms": 7.28.6(@babel/core@7.29.0) "@babel/helpers": 7.29.2 - "@babel/parser": 7.29.2 + "@babel/parser": 7.29.3 "@babel/template": 7.28.6 "@babel/traverse": 7.29.0 "@babel/types": 7.29.0 @@ -9812,7 +10586,7 @@ snapshots: "@babel/generator@7.29.1": dependencies: - "@babel/parser": 7.29.2 + "@babel/parser": 7.29.3 "@babel/types": 7.29.0 "@jridgewell/gen-mapping": 0.3.13 "@jridgewell/trace-mapping": 0.3.31 @@ -9936,10 +10710,6 @@ snapshots: "@babel/template": 7.28.6 "@babel/types": 7.29.0 - "@babel/parser@7.29.2": - dependencies: - "@babel/types": 7.29.0 - "@babel/parser@7.29.3": dependencies: "@babel/types": 7.29.0 @@ -10430,7 +11200,7 @@ snapshots: "@babel/template@7.28.6": dependencies: "@babel/code-frame": 7.29.0 - "@babel/parser": 7.29.2 + "@babel/parser": 7.29.3 "@babel/types": 7.29.0 "@babel/traverse@7.29.0": @@ -10438,7 +11208,7 @@ snapshots: "@babel/code-frame": 7.29.0 "@babel/generator": 7.29.1 "@babel/helper-globals": 7.28.0 - "@babel/parser": 7.29.2 + "@babel/parser": 7.29.3 "@babel/template": 7.28.6 "@babel/types": 7.29.0 debug: 4.4.3 @@ -10454,14 +11224,33 @@ snapshots: "@bcoe/v8-coverage@1.0.2": {} + "@braintree/sanitize-url@6.0.2": {} + "@braintree/sanitize-url@7.1.2": {} "@bramus/specificity@2.4.2": dependencies: css-tree: 3.2.1 + "@chevrotain/cst-dts-gen@11.0.3": + dependencies: + "@chevrotain/gast": 11.0.3 + "@chevrotain/types": 11.0.3 + lodash-es: 4.17.21 + + "@chevrotain/gast@11.0.3": + dependencies: + "@chevrotain/types": 11.0.3 + lodash-es: 4.17.21 + + "@chevrotain/regexp-to-ast@11.0.3": {} + + "@chevrotain/types@11.0.3": {} + "@chevrotain/types@11.1.2": {} + "@chevrotain/utils@11.0.3": {} + "@csstools/color-helpers@6.0.2": {} "@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)": @@ -10499,7 +11288,7 @@ snapshots: "@esbuild/aix-ppc64@0.25.12": optional: true - "@esbuild/aix-ppc64@0.27.4": + "@esbuild/aix-ppc64@0.28.1": optional: true "@esbuild/android-arm64@0.21.5": @@ -10508,7 +11297,7 @@ snapshots: "@esbuild/android-arm64@0.25.12": optional: true - "@esbuild/android-arm64@0.27.4": + "@esbuild/android-arm64@0.28.1": optional: true "@esbuild/android-arm@0.21.5": @@ -10517,7 +11306,7 @@ snapshots: "@esbuild/android-arm@0.25.12": optional: true - "@esbuild/android-arm@0.27.4": + "@esbuild/android-arm@0.28.1": optional: true "@esbuild/android-x64@0.21.5": @@ -10526,7 +11315,7 @@ snapshots: "@esbuild/android-x64@0.25.12": optional: true - "@esbuild/android-x64@0.27.4": + "@esbuild/android-x64@0.28.1": optional: true "@esbuild/darwin-arm64@0.21.5": @@ -10535,7 +11324,7 @@ snapshots: "@esbuild/darwin-arm64@0.25.12": optional: true - "@esbuild/darwin-arm64@0.27.4": + "@esbuild/darwin-arm64@0.28.1": optional: true "@esbuild/darwin-x64@0.21.5": @@ -10544,7 +11333,7 @@ snapshots: "@esbuild/darwin-x64@0.25.12": optional: true - "@esbuild/darwin-x64@0.27.4": + "@esbuild/darwin-x64@0.28.1": optional: true "@esbuild/freebsd-arm64@0.21.5": @@ -10553,7 +11342,7 @@ snapshots: "@esbuild/freebsd-arm64@0.25.12": optional: true - "@esbuild/freebsd-arm64@0.27.4": + "@esbuild/freebsd-arm64@0.28.1": optional: true "@esbuild/freebsd-x64@0.21.5": @@ -10562,7 +11351,7 @@ snapshots: "@esbuild/freebsd-x64@0.25.12": optional: true - "@esbuild/freebsd-x64@0.27.4": + "@esbuild/freebsd-x64@0.28.1": optional: true "@esbuild/linux-arm64@0.21.5": @@ -10571,7 +11360,7 @@ snapshots: "@esbuild/linux-arm64@0.25.12": optional: true - "@esbuild/linux-arm64@0.27.4": + "@esbuild/linux-arm64@0.28.1": optional: true "@esbuild/linux-arm@0.21.5": @@ -10580,7 +11369,7 @@ snapshots: "@esbuild/linux-arm@0.25.12": optional: true - "@esbuild/linux-arm@0.27.4": + "@esbuild/linux-arm@0.28.1": optional: true "@esbuild/linux-ia32@0.21.5": @@ -10589,7 +11378,7 @@ snapshots: "@esbuild/linux-ia32@0.25.12": optional: true - "@esbuild/linux-ia32@0.27.4": + "@esbuild/linux-ia32@0.28.1": optional: true "@esbuild/linux-loong64@0.21.5": @@ -10598,7 +11387,7 @@ snapshots: "@esbuild/linux-loong64@0.25.12": optional: true - "@esbuild/linux-loong64@0.27.4": + "@esbuild/linux-loong64@0.28.1": optional: true "@esbuild/linux-mips64el@0.21.5": @@ -10607,7 +11396,7 @@ snapshots: "@esbuild/linux-mips64el@0.25.12": optional: true - "@esbuild/linux-mips64el@0.27.4": + "@esbuild/linux-mips64el@0.28.1": optional: true "@esbuild/linux-ppc64@0.21.5": @@ -10616,7 +11405,7 @@ snapshots: "@esbuild/linux-ppc64@0.25.12": optional: true - "@esbuild/linux-ppc64@0.27.4": + "@esbuild/linux-ppc64@0.28.1": optional: true "@esbuild/linux-riscv64@0.21.5": @@ -10625,7 +11414,7 @@ snapshots: "@esbuild/linux-riscv64@0.25.12": optional: true - "@esbuild/linux-riscv64@0.27.4": + "@esbuild/linux-riscv64@0.28.1": optional: true "@esbuild/linux-s390x@0.21.5": @@ -10634,7 +11423,7 @@ snapshots: "@esbuild/linux-s390x@0.25.12": optional: true - "@esbuild/linux-s390x@0.27.4": + "@esbuild/linux-s390x@0.28.1": optional: true "@esbuild/linux-x64@0.21.5": @@ -10643,13 +11432,13 @@ snapshots: "@esbuild/linux-x64@0.25.12": optional: true - "@esbuild/linux-x64@0.27.4": + "@esbuild/linux-x64@0.28.1": optional: true "@esbuild/netbsd-arm64@0.25.12": optional: true - "@esbuild/netbsd-arm64@0.27.4": + "@esbuild/netbsd-arm64@0.28.1": optional: true "@esbuild/netbsd-x64@0.21.5": @@ -10658,13 +11447,13 @@ snapshots: "@esbuild/netbsd-x64@0.25.12": optional: true - "@esbuild/netbsd-x64@0.27.4": + "@esbuild/netbsd-x64@0.28.1": optional: true "@esbuild/openbsd-arm64@0.25.12": optional: true - "@esbuild/openbsd-arm64@0.27.4": + "@esbuild/openbsd-arm64@0.28.1": optional: true "@esbuild/openbsd-x64@0.21.5": @@ -10673,13 +11462,13 @@ snapshots: "@esbuild/openbsd-x64@0.25.12": optional: true - "@esbuild/openbsd-x64@0.27.4": + "@esbuild/openbsd-x64@0.28.1": optional: true "@esbuild/openharmony-arm64@0.25.12": optional: true - "@esbuild/openharmony-arm64@0.27.4": + "@esbuild/openharmony-arm64@0.28.1": optional: true "@esbuild/sunos-x64@0.21.5": @@ -10688,7 +11477,7 @@ snapshots: "@esbuild/sunos-x64@0.25.12": optional: true - "@esbuild/sunos-x64@0.27.4": + "@esbuild/sunos-x64@0.28.1": optional: true "@esbuild/win32-arm64@0.21.5": @@ -10697,7 +11486,7 @@ snapshots: "@esbuild/win32-arm64@0.25.12": optional: true - "@esbuild/win32-arm64@0.27.4": + "@esbuild/win32-arm64@0.28.1": optional: true "@esbuild/win32-ia32@0.21.5": @@ -10706,7 +11495,7 @@ snapshots: "@esbuild/win32-ia32@0.25.12": optional: true - "@esbuild/win32-ia32@0.27.4": + "@esbuild/win32-ia32@0.28.1": optional: true "@esbuild/win32-x64@0.21.5": @@ -10715,7 +11504,7 @@ snapshots: "@esbuild/win32-x64@0.25.12": optional: true - "@esbuild/win32-x64@0.27.4": + "@esbuild/win32-x64@0.28.1": optional: true "@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))": @@ -10764,6 +11553,59 @@ snapshots: "@eslint/core": 0.17.0 levn: 0.4.1 + "@excalidraw/excalidraw@0.18.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(immer@11.1.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@braintree/sanitize-url": 6.0.2 + "@excalidraw/laser-pointer": 1.3.1 + "@excalidraw/mermaid-to-excalidraw": 2.2.2 + "@excalidraw/random-username": 1.1.0 + "@radix-ui/react-popover": 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-tabs": 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + browser-fs-access: 0.29.1 + canvas-roundrect-polyfill: 0.0.1 + clsx: 1.1.1 + cross-env: 7.0.3 + es6-promise-pool: 2.5.0 + fractional-indexing: 3.2.0 + fuzzy: 0.1.3 + image-blob-reduce: 3.0.1 + jotai: 2.11.0(@types/react@18.3.28)(react@18.3.1) + jotai-scope: 0.7.2(jotai@2.11.0(@types/react@18.3.28)(react@18.3.1))(react@18.3.1) + lodash.debounce: 4.0.8 + lodash.throttle: 4.1.1 + nanoid: 3.3.3 + open-color: 1.9.1 + pako: 2.0.3 + perfect-freehand: 1.2.0 + pica: 7.1.1 + png-chunk-text: 1.0.0 + png-chunks-encode: 1.0.0 + png-chunks-extract: 1.0.0 + points-on-curve: 1.0.1 + pwacompat: 2.0.17 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + roughjs: 4.6.4 + sass: 1.51.0 + tunnel-rat: 0.1.2(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1) + transitivePeerDependencies: + - "@types/react" + - "@types/react-dom" + - immer + + "@excalidraw/laser-pointer@1.3.1": {} + + "@excalidraw/markdown-to-text@0.1.2": {} + + "@excalidraw/mermaid-to-excalidraw@2.2.2": + dependencies: + "@excalidraw/markdown-to-text": 0.1.2 + "@mermaid-js/parser": 0.6.3 + mermaid: 11.15.0 + nanoid: 4.0.2 + + "@excalidraw/random-username@1.1.0": {} + "@exodus/bytes@1.15.0": {} "@fastify/ajv-compiler@4.0.5": @@ -10996,6 +11838,10 @@ snapshots: "@lukeed/ms@2.0.2": {} + "@mermaid-js/parser@0.6.3": + dependencies: + langium: 3.3.1 + "@mermaid-js/parser@1.1.1": dependencies: "@chevrotain/types": 11.1.2 @@ -11045,8 +11891,23 @@ snapshots: "@radix-ui/number@1.1.1": {} + "@radix-ui/primitive@1.0.0": + dependencies: + "@babel/runtime": 7.29.2 + + "@radix-ui/primitive@1.1.1": {} + "@radix-ui/primitive@1.1.3": {} + "@radix-ui/react-arrow@1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@radix-ui/react-primitive": 2.0.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/react-primitive": 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -11072,6 +11933,16 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-collection@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/react-compose-refs": 1.0.0(react@18.3.1) + "@radix-ui/react-context": 1.0.0(react@18.3.1) + "@radix-ui/react-primitive": 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-slot": 1.0.1(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + "@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/react-compose-refs": 1.1.2(@types/react@18.3.28)(react@18.3.1) @@ -11084,12 +11955,34 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-compose-refs@1.0.0(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + react: 18.3.1 + + "@radix-ui/react-compose-refs@1.1.1(@types/react@18.3.28)(react@18.3.1)": + dependencies: + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@18.3.1)": dependencies: react: 18.3.1 optionalDependencies: "@types/react": 18.3.28 + "@radix-ui/react-context@1.0.0(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + react: 18.3.1 + + "@radix-ui/react-context@1.1.1(@types/react@18.3.28)(react@18.3.1)": + dependencies: + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-context@1.1.2(@types/react@18.3.28)(react@18.3.1)": dependencies: react: 18.3.1 @@ -11118,6 +12011,11 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-direction@1.0.0(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + react: 18.3.1 + "@radix-ui/react-direction@1.1.1(@types/react@18.3.28)(react@18.3.1)": dependencies: react: 18.3.1 @@ -11137,6 +12035,19 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-dismissable-layer@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@radix-ui/primitive": 1.1.1 + "@radix-ui/react-compose-refs": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-primitive": 2.0.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-use-callback-ref": 1.1.0(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-use-escape-keydown": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/primitive": 1.1.3 @@ -11152,12 +12063,29 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-focus-guards@1.1.1(@types/react@18.3.28)(react@18.3.1)": + dependencies: + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.28)(react@18.3.1)": dependencies: react: 18.3.1 optionalDependencies: "@types/react": 18.3.28 + "@radix-ui/react-focus-scope@1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@radix-ui/react-compose-refs": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-primitive": 2.0.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-use-callback-ref": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/react-compose-refs": 1.1.2(@types/react@18.3.28)(react@18.3.1) @@ -11169,6 +12097,19 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-id@1.0.0(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/react-use-layout-effect": 1.0.0(react@18.3.1) + react: 18.3.1 + + "@radix-ui/react-id@1.1.0(@types/react@18.3.28)(react@18.3.1)": + dependencies: + "@radix-ui/react-use-layout-effect": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-id@1.1.1(@types/react@18.3.28)(react@18.3.1)": dependencies: "@radix-ui/react-use-layout-effect": 1.1.1(@types/react@18.3.28)(react@18.3.1) @@ -11225,6 +12166,47 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-popover@1.1.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@radix-ui/primitive": 1.1.1 + "@radix-ui/react-compose-refs": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-context": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-dismissable-layer": 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-focus-guards": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-focus-scope": 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-id": 1.1.0(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-popper": 1.2.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-portal": 1.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-presence": 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-primitive": 2.0.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-slot": 1.1.2(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-use-controllable-state": 1.1.0(@types/react@18.3.28)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + + "@radix-ui/react-popper@1.2.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@floating-ui/react-dom": 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-arrow": 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-compose-refs": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-context": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-primitive": 2.0.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-use-callback-ref": 1.1.0(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-use-layout-effect": 1.1.0(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-use-rect": 1.1.0(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-use-size": 1.1.0(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/rect": 1.1.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@floating-ui/react-dom": 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -11243,6 +12225,16 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-portal@1.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@radix-ui/react-primitive": 2.0.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-use-layout-effect": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/react-primitive": 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -11253,6 +12245,24 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-presence@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/react-compose-refs": 1.0.0(react@18.3.1) + "@radix-ui/react-use-layout-effect": 1.0.0(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + "@radix-ui/react-presence@1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@radix-ui/react-compose-refs": 1.1.1(@types/react@18.3.28)(react@18.3.1) + "@radix-ui/react-use-layout-effect": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/react-compose-refs": 1.1.2(@types/react@18.3.28)(react@18.3.1) @@ -11263,6 +12273,22 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-primitive@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/react-slot": 1.0.1(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + "@radix-ui/react-primitive@2.0.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@radix-ui/react-slot": 1.1.2(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/react-slot": 1.2.3(@types/react@18.3.28)(react@18.3.1) @@ -11281,6 +12307,21 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-roving-focus@1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/primitive": 1.0.0 + "@radix-ui/react-collection": 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-compose-refs": 1.0.0(react@18.3.1) + "@radix-ui/react-context": 1.0.0(react@18.3.1) + "@radix-ui/react-direction": 1.0.0(react@18.3.1) + "@radix-ui/react-id": 1.0.0(react@18.3.1) + "@radix-ui/react-primitive": 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-use-callback-ref": 1.0.0(react@18.3.1) + "@radix-ui/react-use-controllable-state": 1.0.0(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + "@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/primitive": 1.1.3 @@ -11344,6 +12385,19 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-slot@1.0.1(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/react-compose-refs": 1.0.0(react@18.3.1) + react: 18.3.1 + + "@radix-ui/react-slot@1.1.2(@types/react@18.3.28)(react@18.3.1)": + dependencies: + "@radix-ui/react-compose-refs": 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-slot@1.2.3(@types/react@18.3.28)(react@18.3.1)": dependencies: "@radix-ui/react-compose-refs": 1.1.2(@types/react@18.3.28)(react@18.3.1) @@ -11358,6 +12412,20 @@ snapshots: optionalDependencies: "@types/react": 18.3.28 + "@radix-ui/react-tabs@1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/primitive": 1.0.0 + "@radix-ui/react-context": 1.0.0(react@18.3.1) + "@radix-ui/react-direction": 1.0.0(react@18.3.1) + "@radix-ui/react-id": 1.0.0(react@18.3.1) + "@radix-ui/react-presence": 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-primitive": 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-roving-focus": 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@radix-ui/react-use-controllable-state": 1.0.0(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + "@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@radix-ui/primitive": 1.1.3 @@ -11378,12 +12446,36 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/react-use-callback-ref@1.0.0(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + react: 18.3.1 + + "@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.28)(react@18.3.1)": + dependencies: + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.28)(react@18.3.1)": dependencies: react: 18.3.1 optionalDependencies: "@types/react": 18.3.28 + "@radix-ui/react-use-controllable-state@1.0.0(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@radix-ui/react-use-callback-ref": 1.0.0(react@18.3.1) + react: 18.3.1 + + "@radix-ui/react-use-controllable-state@1.1.0(@types/react@18.3.28)(react@18.3.1)": + dependencies: + "@radix-ui/react-use-callback-ref": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.28)(react@18.3.1)": dependencies: "@radix-ui/react-use-effect-event": 0.0.2(@types/react@18.3.28)(react@18.3.1) @@ -11399,6 +12491,13 @@ snapshots: optionalDependencies: "@types/react": 18.3.28 + "@radix-ui/react-use-escape-keydown@1.1.0(@types/react@18.3.28)(react@18.3.1)": + dependencies: + "@radix-ui/react-use-callback-ref": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.28)(react@18.3.1)": dependencies: "@radix-ui/react-use-callback-ref": 1.1.1(@types/react@18.3.28)(react@18.3.1) @@ -11406,6 +12505,17 @@ snapshots: optionalDependencies: "@types/react": 18.3.28 + "@radix-ui/react-use-layout-effect@1.0.0(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + react: 18.3.1 + + "@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.3.28)(react@18.3.1)": + dependencies: + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.28)(react@18.3.1)": dependencies: react: 18.3.1 @@ -11418,6 +12528,13 @@ snapshots: optionalDependencies: "@types/react": 18.3.28 + "@radix-ui/react-use-rect@1.1.0(@types/react@18.3.28)(react@18.3.1)": + dependencies: + "@radix-ui/rect": 1.1.0 + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-use-rect@1.1.1(@types/react@18.3.28)(react@18.3.1)": dependencies: "@radix-ui/rect": 1.1.1 @@ -11425,6 +12542,13 @@ snapshots: optionalDependencies: "@types/react": 18.3.28 + "@radix-ui/react-use-size@1.1.0(@types/react@18.3.28)(react@18.3.1)": + dependencies: + "@radix-ui/react-use-layout-effect": 1.1.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + "@types/react": 18.3.28 + "@radix-ui/react-use-size@1.1.1(@types/react@18.3.28)(react@18.3.1)": dependencies: "@radix-ui/react-use-layout-effect": 1.1.1(@types/react@18.3.28)(react@18.3.1) @@ -11441,6 +12565,8 @@ snapshots: "@types/react": 18.3.28 "@types/react-dom": 18.3.7(@types/react@18.3.28) + "@radix-ui/rect@1.1.0": {} + "@radix-ui/rect@1.1.1": {} "@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1)": @@ -11595,10 +12721,10 @@ snapshots: "@tabby_ai/hijri-converter@1.0.5": {} - "@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))": + "@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.23.0)(yaml@2.8.3))": dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3) + tailwindcss: 3.4.19(tsx@4.23.0)(yaml@2.8.3) "@tanstack/query-core@5.95.2": {} @@ -11632,7 +12758,7 @@ snapshots: "@types/babel__core@7.20.5": dependencies: - "@babel/parser": 7.29.2 + "@babel/parser": 7.29.3 "@babel/types": 7.29.0 "@types/babel__generator": 7.27.0 "@types/babel__template": 7.4.4 @@ -11644,7 +12770,7 @@ snapshots: "@types/babel__template@7.4.4": dependencies: - "@babel/parser": 7.29.2 + "@babel/parser": 7.29.3 "@babel/types": 7.29.0 "@types/babel__traverse@7.28.0": @@ -11946,7 +13072,7 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - "@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@24.12.0)(terser@5.46.1))": + "@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1))": dependencies: "@babel/core": 7.29.0 "@babel/plugin-transform-react-jsx-self": 7.27.1(@babel/core@7.29.0) @@ -11954,11 +13080,11 @@ snapshots: "@rolldown/pluginutils": 1.0.0-beta.27 "@types/babel__core": 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21(@types/node@24.12.0)(terser@5.46.1) + vite: 5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1) transitivePeerDependencies: - supports-color - "@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(terser@5.46.1))": + "@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(sass@1.51.0)(terser@5.46.1))": dependencies: "@ampproject/remapping": 2.3.0 "@bcoe/v8-coverage": 0.2.3 @@ -11972,11 +13098,11 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(terser@5.46.1) + vitest: 2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(sass@1.51.0)(terser@5.46.1) transitivePeerDependencies: - supports-color - "@vitest/coverage-v8@4.1.2(vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))": + "@vitest/coverage-v8@4.1.2(vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.23.0)(yaml@2.8.3)))": dependencies: "@bcoe/v8-coverage": 1.0.2 "@vitest/utils": 4.1.2 @@ -11988,7 +13114,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.23.0)(yaml@2.8.3)) "@vitest/expect@2.1.9": dependencies: @@ -12006,21 +13132,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - "@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.12.0)(terser@5.46.1))": + "@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1))": dependencies: "@vitest/spy": 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@24.12.0)(terser@5.46.1) + vite: 5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1) - "@vitest/mocker@4.1.2(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))": + "@vitest/mocker@4.1.2(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.23.0)(yaml@2.8.3))": dependencies: "@vitest/spy": 4.1.2 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.23.0)(yaml@2.8.3) "@vitest/pretty-format@2.1.9": dependencies: @@ -12309,6 +13435,8 @@ snapshots: dependencies: fill-range: 7.1.1 + browser-fs-access@0.29.1: {} + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.12 @@ -12346,6 +13474,8 @@ snapshots: caniuse-lite@1.0.30001782: {} + canvas-roundrect-polyfill@0.0.1: {} + ccount@2.0.1: {} chai@5.3.3: @@ -12373,6 +13503,20 @@ snapshots: check-error@2.1.3: {} + chevrotain-allstar@0.3.1(chevrotain@11.0.3): + dependencies: + chevrotain: 11.0.3 + lodash-es: 4.18.1 + + chevrotain@11.0.3: + dependencies: + "@chevrotain/cst-dts-gen": 11.0.3 + "@chevrotain/gast": 11.0.3 + "@chevrotain/regexp-to-ast": 11.0.3 + "@chevrotain/types": 11.0.3 + "@chevrotain/utils": 11.0.3 + lodash-es: 4.17.21 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -12406,6 +13550,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clsx@1.1.1: {} + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -12473,10 +13619,16 @@ snapshots: dependencies: layout-base: 2.0.1 + crc-32@0.3.0: {} + croner@10.0.1: {} cronstrue@3.14.0: {} + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -12920,6 +14072,8 @@ snapshots: es-toolkit@1.45.1: {} + es6-promise-pool@2.5.0: {} + esbuild@0.21.5: optionalDependencies: "@esbuild/aix-ppc64": 0.21.5 @@ -12975,35 +14129,34 @@ snapshots: "@esbuild/win32-ia32": 0.25.12 "@esbuild/win32-x64": 0.25.12 - esbuild@0.27.4: + esbuild@0.28.1: optionalDependencies: - "@esbuild/aix-ppc64": 0.27.4 - "@esbuild/android-arm": 0.27.4 - "@esbuild/android-arm64": 0.27.4 - "@esbuild/android-x64": 0.27.4 - "@esbuild/darwin-arm64": 0.27.4 - "@esbuild/darwin-x64": 0.27.4 - "@esbuild/freebsd-arm64": 0.27.4 - "@esbuild/freebsd-x64": 0.27.4 - "@esbuild/linux-arm": 0.27.4 - "@esbuild/linux-arm64": 0.27.4 - "@esbuild/linux-ia32": 0.27.4 - "@esbuild/linux-loong64": 0.27.4 - "@esbuild/linux-mips64el": 0.27.4 - "@esbuild/linux-ppc64": 0.27.4 - "@esbuild/linux-riscv64": 0.27.4 - "@esbuild/linux-s390x": 0.27.4 - "@esbuild/linux-x64": 0.27.4 - "@esbuild/netbsd-arm64": 0.27.4 - "@esbuild/netbsd-x64": 0.27.4 - "@esbuild/openbsd-arm64": 0.27.4 - "@esbuild/openbsd-x64": 0.27.4 - "@esbuild/openharmony-arm64": 0.27.4 - "@esbuild/sunos-x64": 0.27.4 - "@esbuild/win32-arm64": 0.27.4 - "@esbuild/win32-ia32": 0.27.4 - "@esbuild/win32-x64": 0.27.4 - optional: true + "@esbuild/aix-ppc64": 0.28.1 + "@esbuild/android-arm": 0.28.1 + "@esbuild/android-arm64": 0.28.1 + "@esbuild/android-x64": 0.28.1 + "@esbuild/darwin-arm64": 0.28.1 + "@esbuild/darwin-x64": 0.28.1 + "@esbuild/freebsd-arm64": 0.28.1 + "@esbuild/freebsd-x64": 0.28.1 + "@esbuild/linux-arm": 0.28.1 + "@esbuild/linux-arm64": 0.28.1 + "@esbuild/linux-ia32": 0.28.1 + "@esbuild/linux-loong64": 0.28.1 + "@esbuild/linux-mips64el": 0.28.1 + "@esbuild/linux-ppc64": 0.28.1 + "@esbuild/linux-riscv64": 0.28.1 + "@esbuild/linux-s390x": 0.28.1 + "@esbuild/linux-x64": 0.28.1 + "@esbuild/netbsd-arm64": 0.28.1 + "@esbuild/netbsd-x64": 0.28.1 + "@esbuild/openbsd-arm64": 0.28.1 + "@esbuild/openbsd-x64": 0.28.1 + "@esbuild/openharmony-arm64": 0.28.1 + "@esbuild/sunos-x64": 0.28.1 + "@esbuild/win32-arm64": 0.28.1 + "@esbuild/win32-ia32": 0.28.1 + "@esbuild/win32-x64": 0.28.1 escalade@3.2.0: {} @@ -13288,6 +14441,8 @@ snapshots: fraction.js@5.3.4: {} + fractional-indexing@3.2.0: {} + framer-motion@12.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: motion-dom: 12.38.0 @@ -13325,6 +14480,8 @@ snapshots: functions-have-names@1.2.3: {} + fuzzy@0.1.3: {} + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -13361,11 +14518,6 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.13.7: - dependencies: - resolve-pkg-maps: 1.0.0 - optional: true - gitdiff-parser@0.3.1: {} glob-parent@5.1.2: @@ -13403,6 +14555,8 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + glur@1.1.2: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -13512,10 +14666,16 @@ snapshots: ignore@7.0.5: {} + image-blob-reduce@3.0.1: + dependencies: + pica: 7.1.1 + immer@10.2.0: {} immer@11.1.4: {} + immutable@4.3.9: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -13742,6 +14902,16 @@ snapshots: jose@6.2.2: {} + jotai-scope@0.7.2(jotai@2.11.0(@types/react@18.3.28)(react@18.3.1))(react@18.3.1): + dependencies: + jotai: 2.11.0(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + + jotai@2.11.0(@types/react@18.3.28)(react@18.3.1): + optionalDependencies: + "@types/react": 18.3.28 + react: 18.3.1 + jotai@2.19.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1): optionalDependencies: "@babel/core": 7.29.0 @@ -13828,6 +14998,14 @@ snapshots: khroma@2.1.0: {} + langium@3.3.1: + dependencies: + chevrotain: 11.0.3 + chevrotain-allstar: 0.3.1(chevrotain@11.0.3) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + layout-base@1.0.2: {} layout-base@2.0.1: {} @@ -13871,6 +15049,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.17.21: {} + lodash-es@4.18.1: {} lodash.debounce@4.0.8: {} @@ -13879,6 +15059,8 @@ snapshots: lodash.sortby@4.7.0: {} + lodash.throttle@4.1.1: {} + lodash@4.17.23: {} log-update@6.1.0: @@ -14358,6 +15540,11 @@ snapshots: ms@2.1.3: {} + multimath@2.0.0: + dependencies: + glur: 1.1.2 + object-assign: 4.1.1 + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -14366,6 +15553,10 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.3: {} + + nanoid@4.0.2: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} @@ -14443,6 +15634,8 @@ snapshots: dependencies: mimic-function: 5.0.1 + open-color@1.9.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -14470,6 +15663,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@2.0.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -14516,6 +15711,8 @@ snapshots: pathval@2.0.1: {} + perfect-freehand@1.2.0: {} + pg-cloudflare@1.3.0: optional: true @@ -14551,6 +15748,14 @@ snapshots: dependencies: split2: 4.2.0 + pica@7.1.1: + dependencies: + glur: 1.1.2 + inherits: 2.0.4 + multimath: 2.0.0 + object-assign: 4.1.1 + webworkify: 1.5.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -14591,8 +15796,21 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + png-chunk-text@1.0.0: {} + + png-chunks-encode@1.0.0: + dependencies: + crc-32: 0.3.0 + sliced: 1.0.1 + + png-chunks-extract@1.0.0: + dependencies: + crc-32: 0.3.0 + points-on-curve@0.2.0: {} + points-on-curve@1.0.1: {} + points-on-path@0.2.1: dependencies: path-data-parser: 0.1.0 @@ -14612,13 +15830,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.8 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.23.0)(yaml@2.8.3): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.8 - tsx: 4.21.0 + tsx: 4.23.0 yaml: 2.8.3 postcss-nested@6.2.0(postcss@8.5.8): @@ -14687,6 +15905,8 @@ snapshots: punycode@2.3.1: {} + pwacompat@2.0.17: {} + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -14947,9 +16167,6 @@ snapshots: resolve-from@4.0.0: {} - resolve-pkg-maps@1.0.0: - optional: true - resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -15013,6 +16230,13 @@ snapshots: "@rollup/rollup-win32-x64-msvc": 4.60.1 fsevents: 2.3.3 + roughjs@4.6.4: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -15065,6 +16289,12 @@ snapshots: safer-buffer@2.1.2: {} + sass@1.51.0: + dependencies: + chokidar: 3.6.0 + immutable: 4.3.9 + source-map-js: 1.2.1 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -15217,6 +16447,8 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + sliced@1.0.1: {} + smob@1.6.1: {} sonic-boom@4.2.1: @@ -15388,7 +16620,7 @@ snapshots: tailwind-merge@2.6.1: {} - tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3): + tailwindcss@3.4.19(tsx@4.23.0)(yaml@2.8.3): dependencies: "@alloc/quick-lru": 5.2.0 arg: 5.0.2 @@ -15407,7 +16639,7 @@ snapshots: postcss: 8.5.8 postcss-import: 15.1.0(postcss@8.5.8) postcss-js: 4.1.0(postcss@8.5.8) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8)(tsx@4.23.0)(yaml@2.8.3) postcss-nested: 6.2.0(postcss@8.5.8) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -15511,13 +16743,19 @@ snapshots: tslib@2.8.1: {} - tsx@4.21.0: + tsx@4.23.0: dependencies: - esbuild: 0.27.4 - get-tsconfig: 4.13.7 + esbuild: 0.28.1 optionalDependencies: fsevents: 2.3.3 - optional: true + + tunnel-rat@0.1.2(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1): + dependencies: + zustand: 4.5.7(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1) + transitivePeerDependencies: + - "@types/react" + - immer + - react type-check@0.4.0: dependencies: @@ -15707,13 +16945,13 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-node@2.1.9(@types/node@24.12.0)(terser@5.46.1): + vite-node@2.1.9(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21(@types/node@24.12.0)(terser@5.46.1) + vite: 5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1) transitivePeerDependencies: - "@types/node" - less @@ -15725,18 +16963,18 @@ snapshots: - supports-color - terser - vite-plugin-pwa@1.2.0(vite@5.4.21(@types/node@24.12.0)(terser@5.46.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): + vite-plugin-pwa@1.2.0(vite@5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): dependencies: debug: 4.4.3 pretty-bytes: 6.1.1 tinyglobby: 0.2.15 - vite: 5.4.21(@types/node@24.12.0)(terser@5.46.1) + vite: 5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1) workbox-build: 7.4.0(@types/babel__core@7.20.5) workbox-window: 7.4.0 transitivePeerDependencies: - supports-color - vite@5.4.21(@types/node@24.12.0)(terser@5.46.1): + vite@5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1): dependencies: esbuild: 0.21.5 postcss: 8.5.8 @@ -15744,9 +16982,10 @@ snapshots: optionalDependencies: "@types/node": 24.12.0 fsevents: 2.3.3 + sass: 1.51.0 terser: 5.46.1 - vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.23.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -15758,14 +16997,15 @@ snapshots: "@types/node": 24.12.0 fsevents: 2.3.3 jiti: 1.21.7 + sass: 1.51.0 terser: 5.46.1 - tsx: 4.21.0 + tsx: 4.23.0 yaml: 2.8.3 - vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(terser@5.46.1): + vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(sass@1.51.0)(terser@5.46.1): dependencies: "@vitest/expect": 2.1.9 - "@vitest/mocker": 2.1.9(vite@5.4.21(@types/node@24.12.0)(terser@5.46.1)) + "@vitest/mocker": 2.1.9(vite@5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1)) "@vitest/pretty-format": 2.1.9 "@vitest/runner": 2.1.9 "@vitest/snapshot": 2.1.9 @@ -15781,8 +17021,8 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@24.12.0)(terser@5.46.1) - vite-node: 2.1.9(@types/node@24.12.0)(terser@5.46.1) + vite: 5.4.21(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1) + vite-node: 2.1.9(@types/node@24.12.0)(sass@1.51.0)(terser@5.46.1) why-is-node-running: 2.3.0 optionalDependencies: "@types/node": 24.12.0 @@ -15799,10 +17039,10 @@ snapshots: - supports-color - terser - vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.23.0)(yaml@2.8.3)): dependencies: "@vitest/expect": 4.1.2 - "@vitest/mocker": 4.1.2(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + "@vitest/mocker": 4.1.2(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.23.0)(yaml@2.8.3)) "@vitest/pretty-format": 4.1.2 "@vitest/runner": 4.1.2 "@vitest/snapshot": 4.1.2 @@ -15819,7 +17059,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.51.0)(terser@5.46.1)(tsx@4.23.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: "@types/node": 24.12.0 @@ -15828,6 +17068,23 @@ snapshots: transitivePeerDependencies: - msw + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.0.8: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -15840,6 +17097,8 @@ snapshots: webidl-conversions@8.0.1: {} + webworkify@1.5.0: {} + whatwg-mimetype@3.0.0: optional: true @@ -16078,4 +17337,12 @@ snapshots: zod@4.3.6: {} + zustand@4.5.7(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1): + dependencies: + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + immer: 11.1.4 + react: 18.3.1 + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 06b60519..0a0079ed 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,7 @@ packages: - "apps/*" +# pnpm 11 reads these from here, not .npmrc / package.json#pnpm. +shamefullyHoist: true +allowBuilds: + esbuild: true + sharp: true