diff --git a/.claude/skills/radical-diagram/SKILL.md b/.claude/skills/radical-diagram/SKILL.md index 1eeb80d3..d27c9704 100644 --- a/.claude/skills/radical-diagram/SKILL.md +++ b/.claude/skills/radical-diagram/SKILL.md @@ -5,7 +5,7 @@ description: Create or edit .radical diagram files (C4 architecture models for r # Creating .radical diagram files -A `.radical` file is a JSON document (`DiagramData` in `src/renderer/src/types/c4.ts`) that radical.tools opens directly. You can author one by hand from a structure description — no UI needed. +A `.radical` file is a JSON document (`DiagramData` in `packages/common/src/c4.ts`) that radical.tools opens directly. You can author one by hand from a structure description — no UI needed. ## Workflow diff --git a/.claude/skills/radical-diagram/references/radical-file-format.md b/.claude/skills/radical-diagram/references/radical-file-format.md index d1ff2131..db98b930 100644 --- a/.claude/skills/radical-diagram/references/radical-file-format.md +++ b/.claude/skills/radical-diagram/references/radical-file-format.md @@ -1,6 +1,6 @@ # .radical file reference -Full schema lives in `src/renderer/src/types/c4.ts` (`DiagramData`) and `src/renderer/src/types/metamodel.ts` (`Metamodel`). This file summarises the optional sections beyond `nodes` + `relations`. +Full schema lives in `packages/common/src/c4.ts` (`DiagramData`) and `packages/common/src/metamodel/types.ts` (`Metamodel`). This file summarises the optional sections beyond `nodes` + `relations`. ## Top-level shape (`DiagramData`) @@ -95,7 +95,7 @@ Available when using an extended built-in metamodel. Set `"metamodel": { "id": " ## Custom metamodel -Embed a full custom `metamodel` only when built-in presets do not provide the needed types. It needs `id`, `name`, `nodeTypes`, and `relationTypes`. Each node type needs `id`, `label`, `color`, `fg`, `iconPath` (16×16 SVG path), `width`, and `height`; add optional `collapsedWidth`, `collapsedHeight`, `allowedParents`, `allowedAtRoot`, and `properties` as needed. Each relation type needs `id`, `label`, and `allowedPairs: [{from,to}]`. Use `src/renderer/src/types/metamodel.ts` as the authoritative shape. +Embed a full custom `metamodel` only when built-in presets do not provide the needed types. It needs `id`, `name`, `nodeTypes`, and `relationTypes`. Each node type needs `id`, `label`, `color`, `fg`, `iconPath` (16×16 SVG path), `width`, and `height`; add optional `collapsedWidth`, `collapsedHeight`, `allowedParents`, `allowedAtRoot`, and `properties` as needed. Each relation type needs `id`, `label`, and `allowedPairs: [{from,to}]`. Use `packages/common/src/metamodel/types.ts` as the authoritative shape. ## Collapsed sizes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16b3dea2..681d3292 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,5 +18,6 @@ jobs: node-version: 24 cache: npm - run: npm ci + - run: npm run check:workspaces - run: npm run typecheck - run: npm test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index af30fe76..661a32c3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -27,6 +27,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Check workspace boundaries + run: npm run check:workspaces + - name: Type-check run: npm run typecheck @@ -35,9 +38,14 @@ jobs: - name: Build web renderer run: npm run build:web - # Output: out/renderer/{index.html,hub.html,hub-data.json} + out/renderer/assets/** - # + out/renderer/hub/{index.json,/.radical} - # index.html → studio.radical.tools, hub.html → hub.radical.tools (same bundle) + # Output: apps/studio/out/renderer/index.html + apps/studio/out/renderer/assets/** + # → studio.radical.tools + + - name: Build Architecture Hub + run: npm run build:hub + # Output: apps/hub/out/{index.html,hub-data.json} + apps/hub/out/assets/** + # + apps/hub/out/hub/{index.json,/.radical} + # → hub.radical.tools - name: Configure AWS credentials (OIDC — no long-lived keys) uses: aws-actions/configure-aws-credentials@v4 @@ -48,7 +56,7 @@ jobs: - name: Sync static assets to S3 run: | # Vite content-hashes asset filenames → cache forever (immutable) - aws s3 sync out/renderer/assets/ \ + aws s3 sync apps/studio/out/renderer/assets/ \ s3://${{ secrets.S3_BUCKET }}/assets/ \ --cache-control "public, max-age=31536000, immutable" \ --delete @@ -56,7 +64,7 @@ jobs: - name: Upload index.html (no-cache) run: | # index.html is the SPA entry point — must never be served stale - aws s3 cp out/renderer/index.html \ + aws s3 cp apps/studio/out/renderer/index.html \ s3://${{ secrets.S3_BUCKET }}/index.html \ --cache-control "no-store" @@ -71,21 +79,22 @@ jobs: run: | # HTML references landing.css / style.css / theme.js as `?v=__BUILD__`. # Stamping the commit sha busts browser + CDN caches on every deploy. - sed -i "s/__BUILD__/${GITHUB_SHA::8}/g" website/*.html + sed -i "s/__BUILD__/${GITHUB_SHA::8}/g" apps/web/*.html - name: Sync marketing website to S3 run: | - # Static site under website/ — HTML/CSS/screenshots. + # Static site under apps/web/ — HTML/CSS/screenshots. # HTML is uploaded separately below with no-cache headers. - aws s3 sync website/ \ + aws s3 sync apps/web/ \ s3://${{ secrets.WEB_S3_BUCKET }}/ \ --delete \ --exclude "*.html" \ + --exclude "package.json" \ --cache-control "public, max-age=3600" - name: Upload website HTML (no-cache) run: | - for f in website/*.html; do + for f in apps/web/*.html; do aws s3 cp "$f" \ "s3://${{ secrets.WEB_S3_BUCKET }}/$(basename "$f")" \ --cache-control "no-store" \ @@ -99,18 +108,18 @@ jobs: --paths "/index.html" "/manual.html" "/style.css" "/landing.css" "/theme.js" # ── Architecture Hub (hub.radical.tools) ───────────────────────── - # The hub is the same renderer bundle booted from hub.html (embedded - # viewer profile). Assets are content-hashed and shared with the studio. + # Built from apps/hub (the viewer runs on Studio's canvas and panels, + # bundled in). Assets are content-hashed. - name: Sync hub assets to S3 run: | - aws s3 sync out/renderer/assets/ \ + aws s3 sync apps/hub/out/assets/ \ s3://${{ secrets.HUB_S3_BUCKET }}/assets/ \ --cache-control "public, max-age=31536000, immutable" \ --delete - name: Upload hub index.html (no-cache) run: | - aws s3 cp out/renderer/hub.html \ + aws s3 cp apps/hub/out/index.html \ s3://${{ secrets.HUB_S3_BUCKET }}/index.html \ --cache-control "no-store" \ --content-type "text/html; charset=utf-8" @@ -119,7 +128,7 @@ jobs: run: | # hub/index.json + one .radical (JSON) document per concept. # Served as JSON so fetch() content-type checks pass for .radical. - aws s3 sync out/renderer/hub/ \ + aws s3 sync apps/hub/out/hub/ \ s3://${{ secrets.HUB_S3_BUCKET }}/hub/ \ --delete \ --cache-control "no-store" \ @@ -128,7 +137,7 @@ jobs: - name: Upload legacy hub-data.json (no-cache) run: | # Single-file catalogue kept for older desktop builds. - aws s3 cp out/renderer/hub-data.json \ + aws s3 cp apps/hub/out/hub-data.json \ s3://${{ secrets.HUB_S3_BUCKET }}/hub-data.json \ --cache-control "no-store" \ --content-type "application/json; charset=utf-8" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7baf45a2..99bb0966 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,10 @@ on: - 'v*' pull_request: paths: - - electron-builder.yml + - apps/studio/electron-builder.yml + - apps/studio/package.json - .github/workflows/release.yml - - build/** + - apps/studio/build/** workflow_dispatch: permissions: @@ -37,7 +38,7 @@ jobs: - name: Tag matches package.json version if: startsWith(github.ref, 'refs/tags/v') run: | - PKG="v$(node -p "require('./package.json').version")" + PKG="v$(node -p "require('./apps/studio/package.json').version")" if [ "$PKG" != "$GITHUB_REF_NAME" ]; then echo "::error::Tag $GITHUB_REF_NAME does not match package.json version $PKG" exit 1 @@ -71,9 +72,9 @@ jobs: with: name: installers-${{ runner.os }} path: | - dist/*.dmg - dist/*.exe - dist/*.AppImage + apps/studio/dist/*.dmg + apps/studio/dist/*.exe + apps/studio/dist/*.AppImage if-no-files-found: error retention-days: 14 diff --git a/.github/workflows/vscode-publish.yml b/.github/workflows/vscode-publish.yml index 4016acfd..9913bbfa 100644 --- a/.github/workflows/vscode-publish.yml +++ b/.github/workflows/vscode-publish.yml @@ -35,7 +35,7 @@ jobs: cache: npm # ── Quality gate ──────────────────────────────────────────────────────── - - name: Install root dependencies + - name: Install dependencies (all workspaces) run: npm ci - name: Type-check @@ -47,42 +47,38 @@ jobs: # ── Build web renderer ─────────────────────────────────────────────────── - name: Build web renderer run: npm run build:web - # Produces: out/renderer/index.html + out/renderer/assets/** + # Produces: apps/studio/out/renderer/index.html + apps/studio/out/renderer/assets/** # ── Generate icon (idempotent) ─────────────────────────────────────────── - name: Generate extension icon run: node tools/generate-icon.js # ── Extension ──────────────────────────────────────────────────────────── - - name: Install extension dependencies - working-directory: tools/vscode-radical - run: npm ci - - name: Bundle web renderer into extension - working-directory: tools/vscode-radical + working-directory: apps/vscode run: npm run bundle-webview - # Copies out/renderer → tools/vscode-radical/webview/ + # Copies apps/studio/out/renderer → apps/vscode/webview/ - name: Compile extension - working-directory: tools/vscode-radical + working-directory: apps/vscode run: npm run compile # ── Package (always) ───────────────────────────────────────────────────── - name: Package VSIX - working-directory: tools/vscode-radical + working-directory: apps/vscode run: npx @vscode/vsce package --no-dependencies - name: Upload VSIX artifact uses: actions/upload-artifact@v4 with: name: vscode-radical-vsix - path: tools/vscode-radical/*.vsix + path: apps/vscode/*.vsix retention-days: 30 # ── Publish (skipped on dry-run) ───────────────────────────────────────── - name: Publish to VS Code Marketplace if: ${{ !inputs.dry_run }} - working-directory: tools/vscode-radical + working-directory: apps/vscode env: VSCE_PAT: ${{ secrets.VSCE_PAT }} run: npx @vscode/vsce publish --no-dependencies --packagePath *.vsix diff --git a/.gitignore b/.gitignore index 7decc7ba..22b1b687 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,13 @@ out/ .playwright-mcp/ # Visual layout harness output (regenerated locally; large binaries) -tests/visual/out-*.svg -tests/visual/out-*.png +packages/layout/tests/visual/out-*.svg +packages/layout/tests/visual/out-*.png + +# VS Code extension: the webview is the Studio web build, copied in by +# `npm run bundle-webview -w vscode-radical`; packaged .vsix files are CI artifacts +apps/vscode/webview/ +*.vsix # Editor / OS .vscode/ @@ -24,5 +29,5 @@ infra/tfplan infra/override.tf infra/override.tf.json # Note: infra/.terraform.lock.hcl IS committed (pins provider versions) -# Note: hub/**/*.radical (repo root) IS committed — it is the canonical +# Note: packages/hub-catalogue/catalogue/**/*.radical IS committed — it is the canonical # concept catalogue served at hub.radical.tools/hub/ (index.json is generated) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dac03bca..5aeb1125 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ Thank you for your interest in contributing! This document explains how to get i - **Request features** — open a [feature request](https://github.com/radicaltools/radical.tools/issues/new?template=feature_request.md) - **Submit code** — fork the repo and open a pull request - **Improve documentation** — fix typos, clarify explanations, add examples -- **Share architecture patterns** — contribute to the [Architecture Hub](https://hub.radical.tools) +- **Share architecture patterns** — contribute to the [Architecture Hub](https://hub.radical.tools) by adding a `.radical` concept under `packages/hub-catalogue/catalogue/` ## Development setup @@ -25,20 +25,44 @@ npm run typecheck # Type-check ## Project structure +npm workspaces monorepo; run commands from the repo root. + ``` -src/ - main/ — Electron main process - preload/ — Electron preload bridge - renderer/ - src/ - ai/ — AI provider integrations and query language - components/ — React UI components - hooks/ — Custom React hooks - store/ — Zustand state management - types/ — Shared TypeScript types -tests/ — Vitest unit tests +apps/ + studio/ — Radical Studio (Electron + web) and the Hub viewer + src/main/ — Electron main process + src/preload/ — Electron preload bridge + src/renderer/src/ + ai/ — AI chat, providers and Forge + components/ — Studio's own UI (toolbar, extra views, modals) + persistence/ — Autosave between the diagram store and documents + store/ — documentStore + tests/ — Vitest unit tests + vscode/ — VS Code extension + hub/ — Architecture Hub read-only viewer + mcp/ — MCP server (empty for now) + web/ — Marketing site (radical.tools) +packages/ + common/ — @radical/common: model types, metamodels, formats, AI tools (no UI deps) + layout/ — @radical/layout: Smart Layout and headless layout engines + ui/ — @radical/ui: canvas, panels, views and the diagram store (Studio + Hub) + host-bridge/ — @radical/host-bridge: Studio ↔ host contract (Electron, VS Code, browser) + hub-catalogue/ — @radical/hub-catalogue: Hub concepts (.radical files), validation, Vite plugin ``` +## Workspace boundaries + +`npm run check:workspaces` (run in CI) enforces these rules: + +- Packages (`packages/*`) depend only on other packages. Apps (`apps/*`) depend + on packages, never on each other. The exceptions are listed with a reason in + `tools/check-workspaces.mjs`. +- Reach another workspace through its package name and the entry points in + its `exports`, never through a relative path. +- Declare every package you import in your workspace's `package.json`. +- Every workspace with `src/` has a `typecheck` script, and every workspace + with tests has a `test` script. + ## Daily workflow (for maintainers) The `main` branch is protected — direct pushes are rejected. All changes go through a PR. @@ -76,7 +100,7 @@ Branch naming: `feature/`, `hotfix/`, `docs/`. - TypeScript strict mode; no `any` unless unavoidable - React functional components with hooks -- State via Zustand + Immer slices in `src/renderer/src/store/` +- State via Zustand + Immer: the diagram store in `packages/ui/src/store/`, documents in `apps/studio/src/renderer/src/store/` - Tests live in `tests/` and use Vitest ## Commit style diff --git a/README.md b/README.md index d1d1e655..b1efaecc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [**Try it in the browser**](https://studio.radical.tools) · [Architecture Hub](https://hub.radical.tools) · [Manual](https://radical.tools/manual.html) -Demo: opening the sample banking model and switching between its views: system context, container view, payment sequence, dependency matrix, governance table and generated wiki +Demo: opening the sample banking model and switching between its views: system context, container view, payment sequence, dependency matrix, governance table and generated wiki @@ -26,12 +26,12 @@ Architecture diagrams go stale because they are pictures. In radical.tools they - - + + - - + +
Screen-flow of low-fi UI mockups linked by navigation relations
UI mockups & screen flows, AI-generated wireframes linked to requirements
Architecture Hub catalogue of patterns, ADRs and blueprints
Architecture Hub, curated concepts you can import in one click
Screen-flow of low-fi UI mockups linked by navigation relations
UI mockups & screen flows, AI-generated wireframes linked to requirements
Architecture Hub catalogue of patterns, ADRs and blueprints
Architecture Hub, curated concepts you can import in one click
Dependency matrix view of containers
Dependency matrix, one of six views over the same model
Generated architecture wiki page
Architecture wiki, documentation generated from the model
Dependency matrix view of containers
Dependency matrix, one of six views over the same model
Generated architecture wiki page
Architecture wiki, documentation generated from the model
@@ -41,7 +41,7 @@ Open [**studio.radical.tools**](https://studio.radical.tools). No sign-up, nothi **Desktop app:** there are no prebuilt installers yet. To run it as an Electron app, build it from source (see [Getting started](#getting-started)): `npm install && npm run dist` produces an installer for your OS in `dist/`. -**VS Code:** an extension that opens `.radical` files right in the editor lives in [tools/vscode-radical](tools/vscode-radical). +**VS Code:** an extension that opens `.radical` files right in the editor lives in [apps/vscode](apps/vscode). ## Features @@ -88,17 +88,21 @@ npm run typecheck # Run tests npm test -# Production build (outputs to out/) +# Production build (outputs to apps/studio/out/) npm run build # Run built Electron app npm start -# Desktop installers for the current OS (outputs to dist/) +# Desktop installers for the current OS (outputs to apps/studio/dist/) npm run dist # Build web-only renderer (for deployment to studio.radical.tools) npm run build:web + +# Architecture Hub (hub.radical.tools): dev server / production build +npm run dev:hub +npm run build:hub ``` ### Releasing the desktop app (maintainers) @@ -106,7 +110,8 @@ npm run build:web Bump the version and push the tag: ```bash -npm version 1.1.0 # updates package.json and creates the v1.1.0 tag +npm version 1.1.0 -w radical-model # bumps apps/studio/package.json +git commit -am "release: v1.1.0" && git tag v1.1.0 git push --follow-tags ``` @@ -114,31 +119,46 @@ The [Release Desktop App](.github/workflows/release.yml) workflow builds the mac ## Project structure +The repo is an npm workspaces monorepo. Run every command from the root; it +forwards to the right workspace. + ``` -src/ - main/ Electron main process (IPC handlers, file dialogs) - preload/ contextBridge API surface exposed to renderer - renderer/src/ - components/ React UI (Canvas, Toolbar, Panels, Modals, …) - layout/ Layout algorithms (smartLayout, elkLayout, colaLayout, …) - store/ Zustand stores (diagramStore, documentStore, hubStore) - ai/ AI integration (providers: OpenAI, Anthropic, Gemini, Ollama) - types/ C4 + metamodel TypeScript types - hub/ Radical Hub — embedded read-only viewer of the concept - catalogue (hub.html entry → hub.radical.tools) -hub/ The concept catalogue — one Radical Studio document per - concept (/.radical with a `hub` metadata - block); content, kept at the repo root next to website/ - rather than under src/. hub/index.json is generated at - build time -build/ Desktop app icon used by electron-builder -website/ Marketing site (radical.tools) -infra/ Terraform — AWS S3 + CloudFront + Route53 + IAM (OIDC) -tools/ - hubCatalogue.ts Vite plugin — validates hub/**.radical, emits - hub/index.json (+ legacy hub-data.json) - vscode-radical/ VS Code extension for .radical file syntax highlighting -tests/ Vitest unit/integration tests + layout benchmarks +apps/ + studio/ Radical Studio: Electron app + web SPA (studio.radical.tools) + src/main/ Electron main process (IPC handlers, file dialogs) + src/preload/ contextBridge API surface exposed to renderer + src/renderer/src/ + components/ Studio's own UI (Toolbar, Matrix / Sequence / Treemap views, + Forge, document manager, modals, …) + store/ documentStore (localStorage, files, markdown folders) + persistence/ Autosave: keeps the diagram store and the active document in sync + ai/ AI chat, providers (OpenAI, Anthropic, Gemini, Ollama), Forge + platform/ host(): which host Studio runs in (see @radical/host-bridge) + hub/ Importing Hub concepts into a model + tests/ Vitest unit/integration tests + tools/ Sample model generator + build/ Desktop app icon used by electron-builder + hub/ Architecture Hub (hub.radical.tools): read-only viewer, + built on @radical/ui + vscode/ VS Code extension; bundles the Studio web build as its webview + mcp/ MCP server for radical models (empty for now) + web/ Marketing site and manual (radical.tools) +packages/ + common/ @radical/common: C4 + metamodel types, file formats, query + language, AI tool catalogue. No UI dependencies + layout/ @radical/layout: Smart Layout and the headless layout engines, + with their tests, benchmark and visual harness + ui/ @radical/ui: the canvas, panels, Wiki / Table views and the + diagram store, shared by Studio and the Hub. Documents and AI + plug in from the app (store/documentBackend, + components/wireframeGeneration) + host-bridge/ @radical/host-bridge: typed contract between Studio and its + host (Electron, VS Code webview, browser) + hub-catalogue/ @radical/hub-catalogue: the Hub's concept catalogue (one + .radical document per concept), its validation and the Vite + plugin that serves and bundles it for the Hub and Studio +infra/ Terraform: AWS S3 + CloudFront + Route53 + IAM (OIDC) +tools/ Repo-wide scripts (generate-icon.js) docs/ Architecture notes and improvement log ``` diff --git a/src/renderer/hub.html b/apps/hub/index.html similarity index 94% rename from src/renderer/hub.html rename to apps/hub/index.html index dfdcca3a..38f850ec 100644 --- a/src/renderer/hub.html +++ b/apps/hub/index.html @@ -14,6 +14,6 @@
- + diff --git a/apps/hub/package.json b/apps/hub/package.json new file mode 100644 index 00000000..eb2a7ad3 --- /dev/null +++ b/apps/hub/package.json @@ -0,0 +1,30 @@ +{ + "name": "@radical/hub", + "version": "0.0.0", + "private": true, + "description": "Radical Hub (hub.radical.tools): the curated concept catalogue and its read-only viewer.", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@radical/common": "*", + "@radical/ui": "*", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "reactflow": "^11.11.4" + }, + "devDependencies": { + "@radical/hub-catalogue": "*", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.0.10", + "vitest": "^4.1.4" + } +} diff --git a/src/renderer/src/hub/HubApp.tsx b/apps/hub/src/HubApp.tsx similarity index 98% rename from src/renderer/src/hub/HubApp.tsx rename to apps/hub/src/HubApp.tsx index 66cdbe42..1eef2c5f 100644 --- a/src/renderer/src/hub/HubApp.tsx +++ b/apps/hub/src/HubApp.tsx @@ -9,16 +9,22 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ReactFlowProvider } from 'reactflow' -import { useDiagramStore } from '../store/diagramStore' -import { useHubStore, buildConnectionCounts, toggleInSet, type HubConcept, type HubConceptSummary, type HubSortKey } from '../store/hubStore' -import type { HubRadicalDoc } from './hubFormat' -import { Canvas } from '../components/Canvas' -import { WikiView } from '../components/WikiView' -import { TableView } from '../components/TableView' -import { RightPanel } from '../components/RightPanel' -import { NotificationHost } from '../components/NotificationHost' -import { SmartLayoutButton } from '../components/SmartLayoutButton' -import { HUB_CATEGORIES, categoryTheme, type HubCategory } from '../types/hubTheme' +import type { HubCategory, HubConcept, HubConceptSummary, HubRadicalDoc } from '@radical/common/hubFormat' +import { + Canvas, + HUB_CATEGORIES, + NotificationHost, + RightPanel, + SmartLayoutButton, + TableView, + WikiView, + buildConnectionCounts, + categoryTheme, + toggleInSet, + useDiagramStore, + useHubStore, + type HubSortKey, +} from '@radical/ui/viewer' import { conceptToDiagramData, defaultViewKind, diff --git a/src/renderer/src/hub/HubLanding.tsx b/apps/hub/src/HubLanding.tsx similarity index 96% rename from src/renderer/src/hub/HubLanding.tsx rename to apps/hub/src/HubLanding.tsx index cd8c160d..608ae04d 100644 --- a/src/renderer/src/hub/HubLanding.tsx +++ b/apps/hub/src/HubLanding.tsx @@ -4,8 +4,8 @@ // search, pick a category, or open a featured concept straight in the viewer. import React, { useMemo, useState } from 'react' -import type { HubConceptSummary } from '../store/hubStore' -import { HUB_CATEGORIES, categoryTheme, type HubCategory } from '../types/hubTheme' +import type { HubCategory, HubConceptSummary } from '@radical/common/hubFormat' +import { HUB_CATEGORIES, categoryTheme } from '@radical/ui/viewer' import { SiteNav } from './SiteNav' const CATEGORY_BLURB: Record = { diff --git a/src/renderer/src/hub/SiteNav.tsx b/apps/hub/src/SiteNav.tsx similarity index 94% rename from src/renderer/src/hub/SiteNav.tsx rename to apps/hub/src/SiteNav.tsx index 50cf2e82..1ca97c39 100644 --- a/src/renderer/src/hub/SiteNav.tsx +++ b/apps/hub/src/SiteNav.tsx @@ -1,8 +1,8 @@ // ─── Shared site navigation ────────────────────────────────────────────────── // -// The same bar as radical.tools / docs (website/landing.css `.site-nav`): +// The same bar as radical.tools / docs (apps/web/landing.css `.site-nav`): // Home · Docs | Hub · Studio | GitHub · theme. Keep markup and class -// names in sync with website/index.html and website/manual.html. +// names in sync with apps/web/index.html and apps/web/manual.html. import React from 'react' diff --git a/src/renderer/src/hub/conceptToDiagram.ts b/apps/hub/src/conceptToDiagram.ts similarity index 95% rename from src/renderer/src/hub/conceptToDiagram.ts rename to apps/hub/src/conceptToDiagram.ts index 3fa18fad..77a12361 100644 --- a/src/renderer/src/hub/conceptToDiagram.ts +++ b/apps/hub/src/conceptToDiagram.ts @@ -8,11 +8,11 @@ // are provided so the viewer can switch presentation without any store // mutations beyond `setActiveView`. -import type { C4Node, C4Relation, C4ElementType, DiagramData, DiagramSequence, DiagramView } from '../types/c4' -import { NODE_SIZES } from '../types/c4' -import { builtInGovernanceMetamodel } from '../types/metamodel' -import type { HubConcept, TemplateParam } from '../store/hubStore' -import { conceptViewsToDiagram } from './conceptViews' +import type { C4Node, C4Relation, C4ElementType, DiagramData, DiagramSequence, DiagramView } from '@radical/common/c4' +import { NODE_SIZES } from '@radical/common/c4' +import { builtInGovernanceMetamodel } from '@radical/common/metamodel' +import type { HubConcept, TemplateParam } from '@radical/common/hubFormat' +import { conceptViewsToDiagram } from '@radical/ui/viewer' export const HUB_WIKI_VIEW_ID = 'hub-wiki' export const HUB_TABLE_VIEW_ID = 'hub-table' diff --git a/src/renderer/src/hub/hub.css b/apps/hub/src/hub.css similarity index 99% rename from src/renderer/src/hub/hub.css rename to apps/hub/src/hub.css index c2b64227..c1b2a86f 100644 --- a/src/renderer/src/hub/hub.css +++ b/apps/hub/src/hub.css @@ -3,7 +3,7 @@ right. Relies on the design tokens and component styles from index.css. */ /* ═══ Hub palette — emerald, so the Hub reads apart from radical.tools ═══ */ -/* Overrides index.css tokens; this file is loaded only by hub.html, so Studio +/* Overrides index.css tokens; this file is loaded only by the Hub app, so Studio keeps its blue. Both blocks repeat every token because :root here comes after index.css's [data-theme="light"] and would otherwise win in light mode. */ :root { @@ -41,7 +41,7 @@ --edge-label-bg:rgba(255,255,255,0.92); } -/* ═══ Shared site navigation — keep in sync with website/landing.css ════ */ +/* ═══ Shared site navigation — keep in sync with apps/web/landing.css ════ */ .site-nav { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; diff --git a/src/renderer/src/hub/hubRoute.ts b/apps/hub/src/hubRoute.ts similarity index 98% rename from src/renderer/src/hub/hubRoute.ts rename to apps/hub/src/hubRoute.ts index dc61530a..2e09845d 100644 --- a/src/renderer/src/hub/hubRoute.ts +++ b/apps/hub/src/hubRoute.ts @@ -14,7 +14,7 @@ // implies the catalogue too). import type { HubViewKind } from './conceptToDiagram' -import type { HubSortKey } from '../store/hubStore' +import type { HubSortKey } from '@radical/ui/viewer' export interface HubRoute { /** Catalogue is open (implied when concept / category / tag / status is set). */ diff --git a/src/renderer/src/hub.tsx b/apps/hub/src/main.tsx similarity index 67% rename from src/renderer/src/hub.tsx rename to apps/hub/src/main.tsx index 04f4a46c..ff5ebb4e 100644 --- a/src/renderer/src/hub.tsx +++ b/apps/hub/src/main.tsx @@ -1,10 +1,10 @@ import React from 'react' import ReactDOM from 'react-dom/client' -import HubApp from './hub/HubApp' -import './index.css' -import './hub/hub.css' +import { ErrorBoundary } from '@radical/ui/viewer' +import HubApp from './HubApp' +import '@radical/ui/styles.css' +import './hub.css' import 'reactflow/dist/style.css' -import { ErrorBoundary } from './components/ErrorBoundary' ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/tests/hubViewer.test.ts b/apps/hub/tests/hubViewer.test.ts similarity index 97% rename from tests/hubViewer.test.ts rename to apps/hub/tests/hubViewer.test.ts index 53ddde76..3d948f58 100644 --- a/tests/hubViewer.test.ts +++ b/apps/hub/tests/hubViewer.test.ts @@ -16,10 +16,10 @@ import { substituteTemplateDefaults, HUB_WIKI_VIEW_ID, HUB_TABLE_VIEW_ID, -} from '../src/renderer/src/hub/conceptToDiagram' -import { parseHubHash, formatHubHash, studioImportUrl } from '../src/renderer/src/hub/hubRoute' -import { conceptViewsToDiagram } from '../src/renderer/src/hub/conceptViews' -import type { HubConcept } from '../src/renderer/src/store/hubStore' +} from '../src/conceptToDiagram' +import { parseHubHash, formatHubHash, studioImportUrl } from '../src/hubRoute' +import type { HubConcept } from '@radical/common/hubFormat' +import { conceptViewsToDiagram } from '@radical/ui/viewer' const requirement: HubConcept = { id: 'req-api-response-time', diff --git a/apps/hub/tests/setup.ts b/apps/hub/tests/setup.ts new file mode 100644 index 00000000..2cd1345c --- /dev/null +++ b/apps/hub/tests/setup.ts @@ -0,0 +1,2 @@ +// The viewer's diagram store needs window / rAF stubs under node. +import '@radical/ui/testing/setup' diff --git a/apps/hub/tsconfig.json b/apps/hub/tsconfig.json new file mode 100644 index 00000000..4722df77 --- /dev/null +++ b/apps/hub/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": [ + "src/**/*", + "vite.config.ts", + "vitest.config.ts" + ] +} diff --git a/apps/hub/vite.config.ts b/apps/hub/vite.config.ts new file mode 100644 index 00000000..2e08d0e8 --- /dev/null +++ b/apps/hub/vite.config.ts @@ -0,0 +1,23 @@ +/** + * Radical Hub (hub.radical.tools): the concept catalogue plus the read-only + * viewer, built on the canvas and panels in @radical/ui. + * + * Output: out/index.html + out/assets/** + out/hub/{index.json,/.radical} + * + out/hub-data.json (legacy single-file catalogue for older desktop builds). + */ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { resolve } from 'path' +import { hubCataloguePlugin } from '@radical/hub-catalogue/vite' + +export default defineConfig({ + plugins: [react(), hubCataloguePlugin()], + base: './', + build: { + outDir: resolve(__dirname, 'out'), + emptyOutDir: true, + }, + optimizeDeps: { + include: ['reactflow', 'webcola'], + }, +}) diff --git a/apps/hub/vitest.config.ts b/apps/hub/vitest.config.ts new file mode 100644 index 00000000..44a5202f --- /dev/null +++ b/apps/hub/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + setupFiles: ['./tests/setup.ts'], + include: ['tests/**/*.test.ts'], + }, +}) diff --git a/apps/mcp/README.md b/apps/mcp/README.md new file mode 100644 index 00000000..4753d0e7 --- /dev/null +++ b/apps/mcp/README.md @@ -0,0 +1,14 @@ +# @radical/mcp + +MCP server that lets AI agents read and edit `.radical` models. Empty for now. + +Planned shape: + +- Serve the AI tool catalogue from `@radical/common/ai/tools` as MCP tools, so + Studio's AI chat and MCP clients share one set of tools. +- Back those tools with `createModelFacade` from `@radical/common/ai/modelFacade`, + a headless `DiagramFacade` over a plain `DiagramData` loaded from a `.radical` + file. It applies the same rules as Studio's store; apps/studio's + `modelFacadeParity` test keeps the two in step. +- Use `@radical/layout` to place new elements, so models written through MCP + open in Studio already laid out. diff --git a/apps/mcp/package.json b/apps/mcp/package.json new file mode 100644 index 00000000..84ef874f --- /dev/null +++ b/apps/mcp/package.json @@ -0,0 +1,17 @@ +{ + "name": "@radical/mcp", + "version": "0.0.0", + "private": true, + "description": "MCP server for radical models (not implemented yet).", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@radical/common": "*", + "@radical/layout": "*" + }, + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts new file mode 100644 index 00000000..1a74b1bb --- /dev/null +++ b/apps/mcp/src/index.ts @@ -0,0 +1,4 @@ +// Entry point of the radical MCP server. Nothing is implemented yet; see +// README.md for the plan. + +export {} diff --git a/apps/mcp/tsconfig.json b/apps/mcp/tsconfig.json new file mode 100644 index 00000000..bf5a36dd --- /dev/null +++ b/apps/mcp/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"] +} diff --git a/build/icon.png b/apps/studio/build/icon.png similarity index 100% rename from build/icon.png rename to apps/studio/build/icon.png diff --git a/electron-builder.yml b/apps/studio/electron-builder.yml similarity index 87% rename from electron-builder.yml rename to apps/studio/electron-builder.yml index ae271029..08ba3ade 100644 --- a/electron-builder.yml +++ b/apps/studio/electron-builder.yml @@ -6,7 +6,7 @@ copyright: Copyright © 2024 radical.tools contributors artifactName: radical-studio-${version}-${os}-${arch}.${ext} directories: - buildResources: build # icon.png (512×512) — node tools/generate-icon.js --size 512 --out build/icon.png + buildResources: build # icon.png (512×512) — node ../../tools/generate-icon.js --size 512 --out build/icon.png output: dist # electron-vite bundles everything into out/; runtime deps come from package.json. diff --git a/electron.vite.config.ts b/apps/studio/electron.vite.config.ts similarity index 64% rename from electron.vite.config.ts rename to apps/studio/electron.vite.config.ts index 16c2379d..296f7550 100644 --- a/electron.vite.config.ts +++ b/apps/studio/electron.vite.config.ts @@ -1,7 +1,6 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import react from '@vitejs/plugin-react' -import { resolve } from 'path' -import { hubCataloguePlugin } from './tools/hubCatalogue' +import { hubCataloguePlugin } from '@radical/hub-catalogue/vite' export default defineConfig({ main: { @@ -11,12 +10,7 @@ export default defineConfig({ plugins: [externalizeDepsPlugin()] }, renderer: { - plugins: [react(), hubCataloguePlugin({ hubDir: resolve(__dirname, 'hub') })], - define: { - __DEV_SAMPLE_DATA_PATH__: JSON.stringify( - resolve(__dirname, 'src/renderer/src/store/fintechSampleData.json') - ), - }, + plugins: [react(), hubCataloguePlugin()], optimizeDeps: { include: ['reactflow', 'webcola'] }, diff --git a/apps/studio/package.json b/apps/studio/package.json new file mode 100644 index 00000000..1cb81c40 --- /dev/null +++ b/apps/studio/package.json @@ -0,0 +1,53 @@ +{ + "name": "radical-model", + "version": "1.0.0", + "description": "C4 Architecture Diagram Tool", + "author": "radical.tools contributors", + "license": "MIT", + "homepage": "https://radical.tools", + "main": "out/main/index.js", + "exports": { + "./package.json": "./package.json" + }, + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build", + "build:web": "vite build --config vite.web.config.ts", + "dev:web": "vite --config vite.web.config.ts", + "start": "electron-vite preview", + "dist": "npm run build && electron-builder --publish never", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc -p tsconfig.web.json --noEmit && tsc -p tsconfig.node.json --noEmit" + }, + "dependencies": { + "@electron-toolkit/utils": "^2.0.1", + "d3-dispatch": "^3.0.1", + "d3-drag": "^3.0.0", + "d3-timer": "^3.0.1", + "html-to-image": "^1.11.13", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "reactflow": "^11.11.4", + "webcola": "^3.4.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@radical/common": "*", + "@radical/host-bridge": "*", + "@radical/hub-catalogue": "*", + "@radical/ui": "*", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.1", + "electron": "29.4.6", + "electron-builder": "^26.15.3", + "electron-vite": "^2.0.0", + "elkjs": "^0.9.3", + "tsx": "^4.21.0", + "typescript": "^5.3.3", + "vite": "^5.0.10", + "vitest": "^4.1.4" + }, + "private": true +} diff --git a/src/main/index.ts b/apps/studio/src/main/index.ts similarity index 91% rename from src/main/index.ts rename to apps/studio/src/main/index.ts index 940ddad9..7a9af4f9 100644 --- a/src/main/index.ts +++ b/apps/studio/src/main/index.ts @@ -225,30 +225,6 @@ app.whenReady().then(() => { }, ) - // ── DEV: sample model source file helpers ───────────────────────────────── - // Path is resolved once in the main process — no Vite define needed. - if (is.dev) { - const SAMPLE_JSON = resolve(__dirname, '../../src/renderer/src/store/fintechSampleData.json') - - ipcMain.handle('dev:saveSample', async (_event, json: string) => { - try { - await writeFileAsync(SAMPLE_JSON, json, 'utf-8') - return { success: true } - } catch (e) { - return { success: false, error: (e as Error).message } - } - }) - - ipcMain.handle('dev:loadSample', async () => { - try { - const content = await readFileAsync(SAMPLE_JSON, 'utf-8') - return { success: true, content } - } catch (e) { - return { success: false, error: (e as Error).message } - } - }) - } - // ── File watcher: push external changes to renderer ─────────────────────── // When launched with --file or RADICAL_FILE, watch the file with polling // (reliable on all mounts including /Volumes) and push its new content to diff --git a/apps/studio/src/preload/index.d.ts b/apps/studio/src/preload/index.d.ts new file mode 100644 index 00000000..9ceb4122 --- /dev/null +++ b/apps/studio/src/preload/index.d.ts @@ -0,0 +1,9 @@ +import type { HostCapabilities } from '@radical/host-bridge' + +declare global { + interface Window { + /** Set by the Electron preload. Read it only through host() in + * src/renderer/src/platform/host.ts. */ + electronAPI?: HostCapabilities + } +} diff --git a/apps/studio/src/preload/index.ts b/apps/studio/src/preload/index.ts new file mode 100644 index 00000000..f295daa2 --- /dev/null +++ b/apps/studio/src/preload/index.ts @@ -0,0 +1,31 @@ +import { contextBridge, ipcRenderer } from 'electron' +import type { FileChange, HostCapabilities } from '@radical/host-bridge' + +// The Electron implementation of Studio's host bridge: each capability is one +// IPC channel handled in src/main. The renderer reaches it through host() +// (src/renderer/src/platform/host.ts), never through window.electronAPI. +const api = { + saveDiagram: (json) => ipcRenderer.invoke('dialog:save', json), + openDiagram: () => ipcRenderer.invoke('dialog:open'), + readFile: (filePath) => ipcRenderer.invoke('file:read', filePath), + writeFile: (filePath, json) => ipcRenderer.invoke('file:write', filePath, json), + openFolder: () => ipcRenderer.invoke('folder:open'), + pickFolder: () => ipcRenderer.invoke('folder:pick'), + readFolder: (folderPath) => ipcRenderer.invoke('folder:read', folderPath), + writeFolder: (folderPath, files) => ipcRenderer.invoke('folder:write', folderPath, files), + getWatchedPath: () => ipcRenderer.invoke('file:getWatchedPath'), + onFileChanged: (listener) => { + ipcRenderer.on('file:external-change', (_event, data) => listener(data as FileChange)) + }, +} satisfies Required + +if (process.contextIsolated) { + try { + contextBridge.exposeInMainWorld('electronAPI', api) + } catch (error) { + console.error(error) + } +} else { + // @ts-ignore + window.electronAPI = api +} diff --git a/src/renderer/index.html b/apps/studio/src/renderer/index.html similarity index 100% rename from src/renderer/index.html rename to apps/studio/src/renderer/index.html diff --git a/src/renderer/src/App.tsx b/apps/studio/src/renderer/src/App.tsx similarity index 92% rename from src/renderer/src/App.tsx rename to apps/studio/src/renderer/src/App.tsx index 64030288..d6089ff2 100644 --- a/src/renderer/src/App.tsx +++ b/apps/studio/src/renderer/src/App.tsx @@ -1,22 +1,23 @@ import React, { useEffect, useState, useCallback } from 'react' import { ReactFlowProvider } from 'reactflow' +import { host } from './platform/host' import { Toolbar } from './components/Toolbar' -import { Canvas } from './components/Canvas' +import { Canvas } from '@radical/ui/components/Canvas' import { TreemapView } from './components/TreemapView' import { SequenceView } from './components/SequenceView' -import { TableView } from './components/TableView' +import { TableView } from '@radical/ui/components/TableView' import { MatrixView } from './components/MatrixView' -import { WikiView } from './components/WikiView' +import { WikiView } from '@radical/ui/components/WikiView' import { PresentationBar, PresenterDock } from './components/PresentationBar' -import { RightPanel, LeftPanel } from './components/RightPanel' +import { RightPanel, LeftPanel } from '@radical/ui/components/RightPanel' import { MetamodelEditor } from './components/MetamodelEditor' -import { NotificationHost } from './components/NotificationHost' +import { NotificationHost } from '@radical/ui/components/NotificationHost' import { SelectionActionBar } from './components/SelectionActionBar' import { EdgeActionBar } from './components/EdgeActionBar' import { QuickSearch } from './components/QuickSearch' import { WelcomeScreen } from './components/WelcomeScreen' import { MilestoneEditOverlay } from './components/MilestoneEditOverlay' -import { useDiagramStore } from './store/diagramStore' +import { useDiagramStore } from '@radical/ui/store/diagramStore' import { useRouteSync, parseHash } from './route' const LS_LEFT = 'radical-leftpanel-collapsed' @@ -49,8 +50,7 @@ function AppInner(): React.ReactElement { // arrived via a deep link (the hash already encodes a destination) so the // pasted URL lands straight on its target instead of behind the splash. const [showWelcome, setShowWelcome] = useState(() => { - const hasElectron = !!(window as unknown as { electronAPI?: unknown }).electronAPI - if (hasElectron) return false + if (host().kind !== 'web') return false return !parseHash(window.location.hash) }) // Only sync the URL once the welcome splash is gone: while it's up the user diff --git a/src/renderer/src/ai/forgeClarify.ts b/apps/studio/src/renderer/src/ai/forgeClarify.ts similarity index 99% rename from src/renderer/src/ai/forgeClarify.ts rename to apps/studio/src/renderer/src/ai/forgeClarify.ts index 7884b658..263df7d9 100644 --- a/src/renderer/src/ai/forgeClarify.ts +++ b/apps/studio/src/renderer/src/ai/forgeClarify.ts @@ -12,7 +12,7 @@ import { getAdapter } from './registry' import { textOf, type AISettings, type TokenUsage } from './types' -import type { HubConceptSummary } from '../store/hubStore' +import type { HubConceptSummary } from '@radical/ui/store/hubStore' export interface ClarifyStageQuestion { id: string diff --git a/src/renderer/src/ai/forgePrompts.ts b/apps/studio/src/renderer/src/ai/forgePrompts.ts similarity index 99% rename from src/renderer/src/ai/forgePrompts.ts rename to apps/studio/src/renderer/src/ai/forgePrompts.ts index 0cb62b36..a2eb1faf 100644 --- a/src/renderer/src/ai/forgePrompts.ts +++ b/apps/studio/src/renderer/src/ai/forgePrompts.ts @@ -24,7 +24,7 @@ // wireframes are drawn by a separate per-mockup call (ai/mockupWireframe.ts), // triggered from the wizard. -import type { HubConceptSummary } from '../store/hubStore' +import type { HubConceptSummary } from '@radical/ui/store/hubStore' export type ForgeStageId = 'requirements' | 'fitness' | 'scenarios' | 'c4' | 'mockups' diff --git a/src/renderer/src/ai/mockupWireframe.ts b/apps/studio/src/renderer/src/ai/mockupWireframe.ts similarity index 74% rename from src/renderer/src/ai/mockupWireframe.ts rename to apps/studio/src/renderer/src/ai/mockupWireframe.ts index d286c08e..59a6dd9a 100644 --- a/src/renderer/src/ai/mockupWireframe.ts +++ b/apps/studio/src/renderer/src/ai/mockupWireframe.ts @@ -6,21 +6,13 @@ // call to the provider adapter, not a runAIPrompt loop — the output is one // blob of markup, not a sequence of model edits. // -// The SVG is only ever rendered through an data URI (see MockupNode), -// which never runs scripts or fetches external resources; sanitizeWireframeSvg -// still strips the obvious active content so the stored markup is inert even -// if something later inlines it. +// Sanitising and rendering the stored SVG live in @radical/common/wireframe, +// so views can show wireframes without pulling in the AI providers. import { getAdapter } from './registry' import { textOf, type AISettings, type TokenUsage } from './types' -import type { C4Node, C4Relation } from '../types/c4' - -export const WIREFRAME_WIDTH = 400 -export const WIREFRAME_HEIGHT = 300 - -/** Upper bound on stored markup — keeps a model full of mockups from turning - * into megabytes of eagerly-loaded frontmatter. */ -export const MAX_WIREFRAME_CHARS = 20_000 +import type { C4Node, C4Relation } from '@radical/common/c4' +import { WIREFRAME_HEIGHT, WIREFRAME_WIDTH, sanitizeWireframeSvg } from '@radical/common/wireframe' type NodeMap = Record type RelationMap = Record @@ -90,30 +82,6 @@ export function buildWireframePrompt(mockupId: string, nodes: NodeMap, relations return lines.join('\n') } -/** Pulls the first … out of a model response and strips active - * content. Returns null when the response has no usable SVG. */ -export function sanitizeWireframeSvg(text: string): string | null { - const match = text.match(//i) - if (!match) return null - let svg = match[0] - svg = svg - .replace(//gi, '') - .replace(/]*\/>/gi, '') - .replace(//gi, '') - .replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '') - .replace(/\s(?:xlink:)?href\s*=\s*("(?!#)[^"]*"|'(?!#)[^']*')/gi, '') - if (!/\sxmlns=/.test(svg.slice(0, svg.indexOf('>')))) { - svg = svg.replace(/^ MAX_WIREFRAME_CHARS) return null - return svg -} - -/** Data URI for rendering a stored wireframe through . */ -export function wireframeDataUri(svg: string): string { - return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` -} - export interface WireframeResult { svg: string usage?: TokenUsage diff --git a/src/renderer/src/ai/providers/claude.ts b/apps/studio/src/renderer/src/ai/providers/claude.ts similarity index 100% rename from src/renderer/src/ai/providers/claude.ts rename to apps/studio/src/renderer/src/ai/providers/claude.ts diff --git a/src/renderer/src/ai/providers/gemini.ts b/apps/studio/src/renderer/src/ai/providers/gemini.ts similarity index 100% rename from src/renderer/src/ai/providers/gemini.ts rename to apps/studio/src/renderer/src/ai/providers/gemini.ts diff --git a/src/renderer/src/ai/providers/ollama.ts b/apps/studio/src/renderer/src/ai/providers/ollama.ts similarity index 100% rename from src/renderer/src/ai/providers/ollama.ts rename to apps/studio/src/renderer/src/ai/providers/ollama.ts diff --git a/src/renderer/src/ai/providers/openai.ts b/apps/studio/src/renderer/src/ai/providers/openai.ts similarity index 100% rename from src/renderer/src/ai/providers/openai.ts rename to apps/studio/src/renderer/src/ai/providers/openai.ts diff --git a/src/renderer/src/ai/registry.ts b/apps/studio/src/renderer/src/ai/registry.ts similarity index 100% rename from src/renderer/src/ai/registry.ts rename to apps/studio/src/renderer/src/ai/registry.ts diff --git a/src/renderer/src/ai/runner.ts b/apps/studio/src/renderer/src/ai/runner.ts similarity index 99% rename from src/renderer/src/ai/runner.ts rename to apps/studio/src/renderer/src/ai/runner.ts index defbd62b..e27e0cf8 100644 --- a/src/renderer/src/ai/runner.ts +++ b/apps/studio/src/renderer/src/ai/runner.ts @@ -5,10 +5,10 @@ // a single user turn, and repeats until the model returns a plain-text final // answer (no more tool calls) or `maxIterations` is hit. -import { emptyReport, mergeReport, type ApplyReport, type DiagramFacade } from './diagramFacade' +import { emptyReport, mergeReport, type ApplyReport, type DiagramFacade } from '@radical/common/ai/diagramFacade' import { getAdapter } from './registry' import { buildContextMessage, buildSystemMessages } from './systemPrompt' -import { buildToolDefs, buildToolHandlers, type ToolRunContext } from './tools' +import { buildToolDefs, buildToolHandlers, type ToolRunContext } from '@radical/common/ai/tools' import { addTokenUsage, textOf, toolCallsOf, type AISettings, type ChatContentBlock, type ChatMessage, type TokenUsage } from './types' export interface RunAIResult { diff --git a/src/renderer/src/ai/settings.ts b/apps/studio/src/renderer/src/ai/settings.ts similarity index 100% rename from src/renderer/src/ai/settings.ts rename to apps/studio/src/renderer/src/ai/settings.ts diff --git a/src/renderer/src/ai/systemPrompt.ts b/apps/studio/src/renderer/src/ai/systemPrompt.ts similarity index 98% rename from src/renderer/src/ai/systemPrompt.ts rename to apps/studio/src/renderer/src/ai/systemPrompt.ts index b0561d09..8bcd95b1 100644 --- a/src/renderer/src/ai/systemPrompt.ts +++ b/apps/studio/src/renderer/src/ai/systemPrompt.ts @@ -3,8 +3,8 @@ // from prose describing a JSON shape — this file only supplies domain/policy // guidance plus the live metamodel + diagram-state context messages. -import type { C4Node, C4Relation, DiagramView } from '../types/c4' -import type { Metamodel } from '../types/metamodel' +import type { C4Node, C4Relation, DiagramView } from '@radical/common/c4' +import type { Metamodel } from '@radical/common/metamodel' import type { ChatMessage } from './types' const FALLBACK_TYPES = [ diff --git a/src/renderer/src/ai/types.ts b/apps/studio/src/renderer/src/ai/types.ts similarity index 83% rename from src/renderer/src/ai/types.ts rename to apps/studio/src/renderer/src/ai/types.ts index f4178d74..3e586529 100644 --- a/src/renderer/src/ai/types.ts +++ b/apps/studio/src/renderer/src/ai/types.ts @@ -1,5 +1,9 @@ // ─── AI integration: shared types ─────────────────────────────────────────── +import type { ToolDef } from '@radical/common/ai/tools' + +export type { ToolDef } + export type AIProviderId = 'ollama' | 'openai' | 'anthropic' | 'gemini' // ─── Chat wire format (provider-agnostic) ─────────────────────────────────── @@ -28,15 +32,6 @@ export interface ChatMessage { cacheBreakpoint?: boolean } -export interface ToolDef { - name: string - description: string - /** Conservative common-subset JSON Schema (type/properties/required/enum/ - * items/description/additionalProperties) — kept identical across every - * adapter so none has to translate a provider-specific schema dialect. */ - inputSchema: Record -} - export interface ChatRequest { model: string messages: ChatMessage[] @@ -99,20 +94,6 @@ export function toolCallsOf(content: ChatContentBlock[]): Array => b.type === 'tool_call') } -/** Flattens any message content — including tool_call/tool_result blocks — - * to plain text. Used by providers that don't implement real tool-calling - * yet, so a message inherited from a tool-calling-capable provider (e.g. - * after a mid-session provider switch) degrades to readable text instead of - * crashing the request. */ -export function contentToText(content: string | ChatContentBlock[]): string { - if (typeof content === 'string') return content - return content.map((b) => { - if (b.type === 'text') return b.text - if (b.type === 'tool_call') return `[called ${b.name}(${JSON.stringify(b.input)})]` - return `[tool result: ${b.content}]` - }).join('\n') -} - /** Provider configuration entry stored in settings. */ export interface ProviderConfig { /** API key (not used for Ollama). */ diff --git a/src/renderer/src/ai/useDiagramFacade.ts b/apps/studio/src/renderer/src/ai/useDiagramFacade.ts similarity index 89% rename from src/renderer/src/ai/useDiagramFacade.ts rename to apps/studio/src/renderer/src/ai/useDiagramFacade.ts index 4543210b..ddea5726 100644 --- a/src/renderer/src/ai/useDiagramFacade.ts +++ b/apps/studio/src/renderer/src/ai/useDiagramFacade.ts @@ -7,11 +7,16 @@ // this store-binding boilerplate. import { useMemo } from 'react' -import { useDiagramStore } from '../store/diagramStore' -import type { DiagramFacade } from './diagramFacade' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import type { DiagramFacade } from '@radical/common/ai/diagramFacade' export function useDiagramFacade(): DiagramFacade { - return useMemo(() => ({ + return useMemo(createStoreFacade, []) +} + +/** The facade over the live store; also usable outside React (tests). */ +export function createStoreFacade(): DiagramFacade { + return { getNodes: () => useDiagramStore.getState().c4Nodes, getRelations: () => useDiagramStore.getState().c4Relations, getMetamodel: () => useDiagramStore.getState().metamodel, @@ -55,5 +60,5 @@ export function useDiagramFacade(): DiagramFacade { metamodel: s.metamodel, }) }, - }), []) + } } diff --git a/apps/studio/src/renderer/src/ai/wireframeGenerator.ts b/apps/studio/src/renderer/src/ai/wireframeGenerator.ts new file mode 100644 index 00000000..ed00a0ef --- /dev/null +++ b/apps/studio/src/renderer/src/ai/wireframeGenerator.ts @@ -0,0 +1,13 @@ +// Registers Studio's AI wireframe generator with MockupWireframe. The +// generator itself is loaded on first use: it pulls in every provider adapter. + +import { setWireframeGenerator } from '@radical/ui/components/wireframeGeneration' +import { loadAISettings } from './settings' + +setWireframeGenerator({ + enabled: () => loadAISettings().enabled, + async generate(mockupId, nodes, relations, signal) { + const { generateWireframe } = await import('./mockupWireframe') + return generateWireframe(mockupId, nodes, relations, loadAISettings(), signal) + }, +}) diff --git a/src/renderer/src/components/AIReportLine.tsx b/apps/studio/src/renderer/src/components/AIReportLine.tsx similarity index 96% rename from src/renderer/src/components/AIReportLine.tsx rename to apps/studio/src/renderer/src/components/AIReportLine.tsx index f17be36c..1550ac50 100644 --- a/src/renderer/src/components/AIReportLine.tsx +++ b/apps/studio/src/renderer/src/components/AIReportLine.tsx @@ -1,5 +1,5 @@ import React from 'react' -import type { ApplyReport } from '../ai/diagramFacade' +import type { ApplyReport } from '@radical/common/ai/diagramFacade' /** Compact one-line summary of an AI run's `ApplyReport` — "+3 nodes, * +2 relations" etc. — plus any tool errors. Shared by every AI entry point diff --git a/src/renderer/src/components/AISettingsModal.tsx b/apps/studio/src/renderer/src/components/AISettingsModal.tsx similarity index 100% rename from src/renderer/src/components/AISettingsModal.tsx rename to apps/studio/src/renderer/src/components/AISettingsModal.tsx diff --git a/src/renderer/src/components/DocumentManager.tsx b/apps/studio/src/renderer/src/components/DocumentManager.tsx similarity index 98% rename from src/renderer/src/components/DocumentManager.tsx rename to apps/studio/src/renderer/src/components/DocumentManager.tsx index 425b81db..52eccc7e 100644 --- a/src/renderer/src/components/DocumentManager.tsx +++ b/apps/studio/src/renderer/src/components/DocumentManager.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { documents, useDocumentsStore, type DocumentMeta, type DocumentSource } from '../store/documentStore' -import { useDiagramStore } from '../store/diagramStore' -import { availableMetamodels } from '../types/metamodel' -import { parseStructurizrDsl } from '../import/structurizrDsl' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import { availableMetamodels } from '@radical/common/metamodel' +import { parseStructurizrDsl } from '@radical/common/formats/structurizrDsl' import { webFolderSupported } from '../persist/webFolder' +import { host } from '../platform/host' interface Props { open: boolean @@ -18,7 +19,7 @@ function fmtTime(ts: number): string { return d.toLocaleString() } -const isElectron = typeof window !== 'undefined' && !!window.electronAPI?.openFolder +const isElectron = !!host().openFolder /** Folder-of-Markdown mode works in Electron and in Chromium browsers (File * System Access API). */ const folderSupported = isElectron || webFolderSupported() diff --git a/src/renderer/src/components/EdgeActionBar.tsx b/apps/studio/src/renderer/src/components/EdgeActionBar.tsx similarity index 98% rename from src/renderer/src/components/EdgeActionBar.tsx rename to apps/studio/src/renderer/src/components/EdgeActionBar.tsx index 3634478f..72b6a20b 100644 --- a/src/renderer/src/components/EdgeActionBar.tsx +++ b/apps/studio/src/renderer/src/components/EdgeActionBar.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' -import { useDiagramStore } from '../store/diagramStore' -import { isRelationAllowed } from '../types/metamodel' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import { isRelationAllowed } from '@radical/common/metamodel' /** * Floating action bar that appears over the canvas whenever a single diff --git a/src/renderer/src/components/HubImportModal.tsx b/apps/studio/src/renderer/src/components/HubImportModal.tsx similarity index 99% rename from src/renderer/src/components/HubImportModal.tsx rename to apps/studio/src/renderer/src/components/HubImportModal.tsx index 555e59d3..8a14113f 100644 --- a/src/renderer/src/components/HubImportModal.tsx +++ b/apps/studio/src/renderer/src/components/HubImportModal.tsx @@ -1,12 +1,12 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' -import { useHubStore, toggleInSet, type HubConcept, type HubConceptSummary, type TemplateParam, type HubImportRecord } from '../store/hubStore' -import { useDiagramStore } from '../store/diagramStore' -import type { C4Node, C4Relation, C4ElementType, DiagramSequence } from '../types/c4' -import { NODE_SIZES } from '../types/c4' -import { isParentAllowed } from '../types/metamodel' -import { nodeTypeTheme } from '../types/hubTheme' -import { conceptViewsToDiagram } from '../hub/conceptViews' +import { useHubStore, toggleInSet, type HubConcept, type HubConceptSummary, type TemplateParam, type HubImportRecord } from '@radical/ui/store/hubStore' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import type { C4Node, C4Relation, C4ElementType, DiagramSequence } from '@radical/common/c4' +import { NODE_SIZES } from '@radical/common/c4' +import { isParentAllowed } from '@radical/common/metamodel' +import { nodeTypeTheme } from '@radical/ui/types/hubTheme' +import { conceptViewsToDiagram } from '@radical/ui/hub/conceptViews' // ─── Props ────────────────────────────────────────────────────────────────── diff --git a/src/renderer/src/components/MatrixView.tsx b/apps/studio/src/renderer/src/components/MatrixView.tsx similarity index 98% rename from src/renderer/src/components/MatrixView.tsx rename to apps/studio/src/renderer/src/components/MatrixView.tsx index 5aae4f26..1b86f0f4 100644 --- a/src/renderer/src/components/MatrixView.tsx +++ b/apps/studio/src/renderer/src/components/MatrixView.tsx @@ -4,10 +4,10 @@ import { computeViewCollapsedSet, isNodeHidden, getViewVisibleAncestor, -} from '../store/diagramStore' -import type { C4Node } from '../types/c4' -import { NODE_COLORS, TYPE_LABELS } from '../types/c4' -import { isRelationAllowed } from '../types/metamodel' +} from '@radical/ui/store/diagramStore' +import type { C4Node } from '@radical/common/c4' +import { NODE_COLORS, TYPE_LABELS } from '@radical/common/c4' +import { isRelationAllowed } from '@radical/common/metamodel' // ─── Tree ordering helper (hierarchical, parents before children) ──────────── diff --git a/src/renderer/src/components/MetamodelEditor.tsx b/apps/studio/src/renderer/src/components/MetamodelEditor.tsx similarity index 99% rename from src/renderer/src/components/MetamodelEditor.tsx rename to apps/studio/src/renderer/src/components/MetamodelEditor.tsx index ac4a52f7..329fe163 100644 --- a/src/renderer/src/components/MetamodelEditor.tsx +++ b/apps/studio/src/renderer/src/components/MetamodelEditor.tsx @@ -1,14 +1,14 @@ import React, { useState, useMemo, useEffect } from 'react' import { createPortal } from 'react-dom' -import { useDiagramStore } from '../store/diagramStore' -import { builtInC4Metamodel, validateModel } from '../types/metamodel' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import { builtInC4Metamodel, validateModel } from '@radical/common/metamodel' import type { NodeTypeDef, RelationTypeDef, RelationPair, PropertyDef, PropertyType, -} from '../types/metamodel' +} from '@radical/common/metamodel' // ── small icon helpers ───────────────────────────────────────────────────── diff --git a/src/renderer/src/components/MilestoneEditOverlay.tsx b/apps/studio/src/renderer/src/components/MilestoneEditOverlay.tsx similarity index 98% rename from src/renderer/src/components/MilestoneEditOverlay.tsx rename to apps/studio/src/renderer/src/components/MilestoneEditOverlay.tsx index f6d3163d..2731b8ca 100644 --- a/src/renderer/src/components/MilestoneEditOverlay.tsx +++ b/apps/studio/src/renderer/src/components/MilestoneEditOverlay.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react' -import { useDiagramStore } from '../store/diagramStore' +import { useDiagramStore } from '@radical/ui/store/diagramStore' /** * Blocking modal that appears when the user tries to switch milestones diff --git a/src/renderer/src/components/PresentationBar.tsx b/apps/studio/src/renderer/src/components/PresentationBar.tsx similarity index 82% rename from src/renderer/src/components/PresentationBar.tsx rename to apps/studio/src/renderer/src/components/PresentationBar.tsx index 543461a9..4a43195b 100644 --- a/src/renderer/src/components/PresentationBar.tsx +++ b/apps/studio/src/renderer/src/components/PresentationBar.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef, useEffect } from 'react' -import { useDiagramStore } from '../store/diagramStore' -import type { DiagramView, PresentationSlide } from '../types/c4' -import { useOutsideClick } from '../hooks/useOutsideClick' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import type { DiagramView, PresentationSlide } from '@radical/common/c4' +import { useOutsideClick } from '@radical/ui/hooks/useOutsideClick' // ── SVG icons ──────────────────────────────────────────────────────────────── @@ -269,8 +269,6 @@ function PresenterHUD() { ) } -// ── SlidesColumn — embeddable slides panel ──────────────────────────────────── - function PresentationPicker() { const presentations = useDiagramStore(s => s.presentations) const activeId = useDiagramStore(s => s.activePresentationId) @@ -337,80 +335,6 @@ function PresentationPicker() { ) } -export function SlidesColumn({ readOnly = false }: { readOnly?: boolean } = {}): React.ReactElement { - const slides = useDiagramStore(s => s.presentationSlides) - const idx = useDiagramStore(s => s.presentationSlideIndex) - const presentationActive = useDiagramStore(s => s.presentationActive) - const snapshots = useDiagramStore(s => s.snapshots) - const views = useDiagramStore(s => s.views) - - const addPresentationSlide = useDiagramStore(s => s.addPresentationSlide) - const removePresentationSlide = useDiagramStore(s => s.removePresentationSlide) - const renamePresentationSlide = useDiagramStore(s => s.renamePresentationSlide) - const captureSlideViewport = useDiagramStore(s => s.captureSlideViewport) - const linkSnapshotToSlide = useDiagramStore(s => s.linkSnapshotToSlide) - const linkViewToSlide = useDiagramStore(s => s.linkViewToSlide) - const startPresentation = useDiagramStore(s => s.startPresentation) - const stopPresentation = useDiagramStore(s => s.stopPresentation) - const goToSlide = useDiagramStore(s => s.goToSlide) - const previewSlide = useDiagramStore(s => s.previewSlide) - - const snapshotNames: Record = {} - for (const s of snapshots) snapshotNames[s.id] = s.name - - const viewNames: Record = {} - for (const v of Object.values(views)) viewNames[v.id] = v.name - - const handleSlideSelect = (i: number) => { - if (presentationActive) { - if (i === idx) stopPresentation() - else goToSlide(i) - } else { - previewSlide(i) - } - } - - return ( -
- -
- - {!readOnly && ( - - )} -
-
- {slides.length === 0 && ( -
- {readOnly ? 'No slides in this presentation.' : <>No slides yet — click Add slide.} -
- )} - {slides.map((slide, i) => ( - handleSlideSelect(i)} - onDelete={() => removePresentationSlide(slide.id)} - onRename={(name) => renamePresentationSlide(slide.id, name)} - onCapture={() => captureSlideViewport(slide.id)} - onLinkSnapshot={(snapId) => linkSnapshotToSlide(slide.id, snapId)} - onLinkView={(viewId) => linkViewToSlide(slide.id, viewId)} - /> - ))} -
-
- ) -} - -// ── PresenterDock — horizontal slides bar at the bottom (presenter mode) ───── - export function PresenterDock({ readOnly = false }: { readOnly?: boolean } = {}): React.ReactElement { const slides = useDiagramStore(s => s.presentationSlides) const idx = useDiagramStore(s => s.presentationSlideIndex) diff --git a/src/renderer/src/components/QuickSearch.tsx b/apps/studio/src/renderer/src/components/QuickSearch.tsx similarity index 99% rename from src/renderer/src/components/QuickSearch.tsx rename to apps/studio/src/renderer/src/components/QuickSearch.tsx index 8df0ce98..92589216 100644 --- a/src/renderer/src/components/QuickSearch.tsx +++ b/apps/studio/src/renderer/src/components/QuickSearch.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useDiagramStore } from '../store/diagramStore' -import { useOutsideClick } from '../hooks/useOutsideClick' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import { useOutsideClick } from '@radical/ui/hooks/useOutsideClick' import { runAIPrompt } from '../ai/runner' import { loadAISettings } from '../ai/settings' import { getAdapter, listAdapters } from '../ai/registry' @@ -8,7 +8,7 @@ import { useDiagramFacade } from '../ai/useDiagramFacade' import { AIReportLine } from './AIReportLine' import { openAISettings } from './AISettingsModal' import type { AISettings, ChatMessage, TokenUsage } from '../ai/types' -import type { ApplyReport } from '../ai/diagramFacade' +import type { ApplyReport } from '@radical/common/ai/diagramFacade' /** * Cmd/Ctrl+P quick-search palette. diff --git a/src/renderer/src/components/RadicalForgeModal.tsx b/apps/studio/src/renderer/src/components/RadicalForgeModal.tsx similarity index 99% rename from src/renderer/src/components/RadicalForgeModal.tsx rename to apps/studio/src/renderer/src/components/RadicalForgeModal.tsx index df58fe9a..3a377fa9 100644 --- a/src/renderer/src/components/RadicalForgeModal.tsx +++ b/apps/studio/src/renderer/src/components/RadicalForgeModal.tsx @@ -1,14 +1,15 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { useDiagramStore } from '../store/diagramStore' +import { useDiagramStore } from '@radical/ui/store/diagramStore' import { runAIPrompt, type ForgeProgressEvent } from '../ai/runner' import { loadAISettings } from '../ai/settings' import { getAdapter } from '../ai/registry' import { useDiagramFacade } from '../ai/useDiagramFacade' import { FORGE_STAGES, PRIMARY_TYPE_IDS_FOR_STAGE, buildForgeStagePrompt, buildPriorStagesBlock, type ForgeStageId } from '../ai/forgePrompts' -import { buildGherkinFiles, downloadGherkinFiles } from '../export/exportGherkin' +import { buildGherkinFiles } from '@radical/common/formats/exportGherkin' +import { downloadGherkinFiles } from '../export/downloadGherkinFiles' import { AIReportLine } from './AIReportLine' -import { useHubStore, type HubCategory, type HubConceptSummary } from '../store/hubStore' +import { useHubStore, type HubCategory, type HubConceptSummary } from '@radical/ui/store/hubStore' import { findRelevantConcepts } from '../hub/matchConcepts' import { importHubConceptIntoDiagram } from '../hub/importConcept' import { @@ -18,7 +19,7 @@ import { type ClarifyStageQuestion, } from '../ai/forgeClarify' import { addTokenUsage, type AISettings, type TokenUsage } from '../ai/types' -import type { ApplyReport } from '../ai/diagramFacade' +import type { ApplyReport } from '@radical/common/ai/diagramFacade' import { generateWireframe } from '../ai/mockupWireframe' type ClarifyStatus = 'asking' | 'form' | 'done' diff --git a/src/renderer/src/components/SelectionActionBar.tsx b/apps/studio/src/renderer/src/components/SelectionActionBar.tsx similarity index 99% rename from src/renderer/src/components/SelectionActionBar.tsx rename to apps/studio/src/renderer/src/components/SelectionActionBar.tsx index 7e740192..2d103457 100644 --- a/src/renderer/src/components/SelectionActionBar.tsx +++ b/apps/studio/src/renderer/src/components/SelectionActionBar.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState, useRef, useEffect } from 'react' -import { useDiagramStore } from '../store/diagramStore' -import { isParentAllowed } from '../types/metamodel' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import { isParentAllowed } from '@radical/common/metamodel' /** * Floating action bar that appears over the canvas whenever one or more diff --git a/src/renderer/src/components/SequenceView.tsx b/apps/studio/src/renderer/src/components/SequenceView.tsx similarity index 99% rename from src/renderer/src/components/SequenceView.tsx rename to apps/studio/src/renderer/src/components/SequenceView.tsx index b3f1c5bc..194d3b65 100644 --- a/src/renderer/src/components/SequenceView.tsx +++ b/apps/studio/src/renderer/src/components/SequenceView.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react' -import { useDiagramStore } from '../store/diagramStore' -import { NODE_COLORS, NODE_FG, TYPE_LABELS } from '../types/c4' -import type { C4Node, C4Relation } from '../types/c4' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import { NODE_COLORS, NODE_FG, TYPE_LABELS } from '@radical/common/c4' +import type { C4Node, C4Relation } from '@radical/common/c4' // Participants use the same fills and shapes as the C4 nodes on the canvas // (see components/nodes/C4Nodes.tsx), so a flow reads like the structure view. diff --git a/src/renderer/src/components/Toolbar.tsx b/apps/studio/src/renderer/src/components/Toolbar.tsx similarity index 98% rename from src/renderer/src/components/Toolbar.tsx rename to apps/studio/src/renderer/src/components/Toolbar.tsx index 7d3ec8d2..5c645a94 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/apps/studio/src/renderer/src/components/Toolbar.tsx @@ -1,12 +1,13 @@ import React, { useCallback, useState, useEffect, useRef } from 'react' -import { useDiagramStore } from '../store/diagramStore' -import { SmartLayoutButton } from './SmartLayoutButton' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import { host } from '../platform/host' +import { SmartLayoutButton } from '@radical/ui/components/SmartLayoutButton' import { useDocumentsStore, type DocumentSource } from '../store/documentStore' import { DocumentManagerModal } from './DocumentManager' import { AISettingsModal } from './AISettingsModal' import { HubImportModal } from './HubImportModal' import { RadicalForgeModal } from './RadicalForgeModal' -import { useOutsideClick } from '../hooks/useOutsideClick' +import { useOutsideClick } from '@radical/ui/hooks/useOutsideClick' import { useExport } from '../hooks/useExport' import { loadStudioSettings, @@ -15,7 +16,7 @@ import { WIKI_MULTI_PAGE_DEPTH_MIN, WIKI_MULTI_PAGE_DEPTH_MAX, type StudioSettings, -} from '../studioSettings' +} from '@radical/ui/studioSettings' // ── SVG icons ──────────────────────────────────────────────────────────────── @@ -511,7 +512,9 @@ export function Toolbar(): React.ReactElement { const [managerOpen, setManagerOpen] = useState(false) - const isVSCode = !!(window as any).electronAPI + // Only the VS Code webview follows the editor theme; the desktop app and + // the browser keep the user's own choice. + const isVSCode = host().kind === 'vscode' const [theme, setTheme] = useState<'dark' | 'light'>(() => { if (isVSCode) { diff --git a/src/renderer/src/components/TreemapView.tsx b/apps/studio/src/renderer/src/components/TreemapView.tsx similarity index 99% rename from src/renderer/src/components/TreemapView.tsx rename to apps/studio/src/renderer/src/components/TreemapView.tsx index 0705f19f..d111a2cb 100644 --- a/src/renderer/src/components/TreemapView.tsx +++ b/apps/studio/src/renderer/src/components/TreemapView.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useRef, useEffect, useLayoutEffect, useState, useCallback } from 'react' -import { useDiagramStore } from '../store/diagramStore' -import type { C4Node, C4Relation } from '../types/c4' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import type { C4Node, C4Relation } from '@radical/common/c4' // ─── Color palette per C4 type ────────────────────────────────────────────── const TYPE_COLORS_LIGHT: Record = { diff --git a/src/renderer/src/components/WelcomeScreen.tsx b/apps/studio/src/renderer/src/components/WelcomeScreen.tsx similarity index 99% rename from src/renderer/src/components/WelcomeScreen.tsx rename to apps/studio/src/renderer/src/components/WelcomeScreen.tsx index 9482184f..1f52754e 100644 --- a/src/renderer/src/components/WelcomeScreen.tsx +++ b/apps/studio/src/renderer/src/components/WelcomeScreen.tsx @@ -1,9 +1,9 @@ import React, { useState } from 'react' import { documents } from '../store/documentStore' import { buildFintechSampleRaw } from '../store/fintechSample' -import { useDiagramStore } from '../store/diagramStore' +import { useDiagramStore } from '@radical/ui/store/diagramStore' import { formatRoute } from '../route' -import { availableMetamodels } from '../types/metamodel' +import { availableMetamodels } from '@radical/common/metamodel' /** The sample's System Context view (fintechSampleData.json). */ const SAMPLE_START_VIEW = 'view-ctx' diff --git a/apps/studio/src/renderer/src/export/downloadGherkinFiles.ts b/apps/studio/src/renderer/src/export/downloadGherkinFiles.ts new file mode 100644 index 00000000..f9f8e881 --- /dev/null +++ b/apps/studio/src/renderer/src/export/downloadGherkinFiles.ts @@ -0,0 +1,23 @@ +import type { GherkinFile } from '@radical/common/formats/exportGherkin' + +/** Triggers a browser download for each file, staggered so Safari/Firefox + * don't drop downloads fired in the same tick (mirrors the pattern in + * documentStore.ts's defaultWebDownloader). */ +export function downloadGherkinFiles(files: GherkinFile[]): void { + files.forEach((file, i) => { + setTimeout(() => { + const blob = new Blob([file.content], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = file.filename + a.style.display = 'none' + document.body.appendChild(a) + a.click() + setTimeout(() => { + try { document.body.removeChild(a) } catch { /* already gone */ } + URL.revokeObjectURL(url) + }, 1000) + }, i * 250) + }) +} diff --git a/src/renderer/src/hooks/useExport.ts b/apps/studio/src/renderer/src/hooks/useExport.ts similarity index 98% rename from src/renderer/src/hooks/useExport.ts rename to apps/studio/src/renderer/src/hooks/useExport.ts index 8ac8b9b4..f9d8722c 100644 --- a/src/renderer/src/hooks/useExport.ts +++ b/apps/studio/src/renderer/src/hooks/useExport.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react' import { toPng, toSvg } from 'html-to-image' -import { useDiagramStore } from '../store/diagramStore' +import { useDiagramStore } from '@radical/ui/store/diagramStore' import { useDocumentsStore } from '../store/documentStore' function sanitizeFilename(name: string): string { diff --git a/src/renderer/src/hub/importConcept.ts b/apps/studio/src/renderer/src/hub/importConcept.ts similarity index 97% rename from src/renderer/src/hub/importConcept.ts rename to apps/studio/src/renderer/src/hub/importConcept.ts index fdb3941e..4e307244 100644 --- a/src/renderer/src/hub/importConcept.ts +++ b/apps/studio/src/renderer/src/hub/importConcept.ts @@ -6,11 +6,11 @@ // that just want "drop this concept into the model" with no modal around it // (Radical Forge's Hub suggestion cards). -import type { C4ElementType, C4Node, C4Relation, DiagramSequence } from '../types/c4' -import { NODE_SIZES } from '../types/c4' -import { useDiagramStore } from '../store/diagramStore' -import type { HubConcept, HubImportRecord, TemplateParam } from '../store/hubStore' -import { conceptViewsToDiagram } from './conceptViews' +import type { C4ElementType, C4Node, C4Relation, DiagramSequence } from '@radical/common/c4' +import { NODE_SIZES } from '@radical/common/c4' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import type { HubConcept, HubImportRecord, TemplateParam } from '@radical/ui/store/hubStore' +import { conceptViewsToDiagram } from '@radical/ui/hub/conceptViews' export interface ImportHubConceptResult { nodeIds: string[] diff --git a/src/renderer/src/hub/matchConcepts.ts b/apps/studio/src/renderer/src/hub/matchConcepts.ts similarity index 97% rename from src/renderer/src/hub/matchConcepts.ts rename to apps/studio/src/renderer/src/hub/matchConcepts.ts index 6090b93f..6a42d486 100644 --- a/src/renderer/src/hub/matchConcepts.ts +++ b/apps/studio/src/renderer/src/hub/matchConcepts.ts @@ -5,7 +5,7 @@ // that simplicity. Used by Radical Forge to surface prior art from the Hub // and to ground the AI's own generation in it (see ai/forgePrompts.ts). -import type { HubCategory, HubConceptSummary } from '../store/hubStore' +import type { HubCategory, HubConceptSummary } from '@radical/ui/store/hubStore' const STOPWORDS = new Set([ 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'her', 'was', diff --git a/src/renderer/src/main.tsx b/apps/studio/src/renderer/src/main.tsx similarity index 53% rename from src/renderer/src/main.tsx rename to apps/studio/src/renderer/src/main.tsx index ef9eb4c6..4204dcc5 100644 --- a/src/renderer/src/main.tsx +++ b/apps/studio/src/renderer/src/main.tsx @@ -1,9 +1,13 @@ +// First: document persistence configures the store before it is created. +import './persistence/autosave' +import './ai/wireframeGenerator' import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' -import './index.css' +import '@radical/ui/styles.css' +import './studio.css' import 'reactflow/dist/style.css' -import { ErrorBoundary } from './components/ErrorBoundary' +import { ErrorBoundary } from '@radical/ui/components/ErrorBoundary' ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/src/renderer/src/persist/webFolder.ts b/apps/studio/src/renderer/src/persist/webFolder.ts similarity index 99% rename from src/renderer/src/persist/webFolder.ts rename to apps/studio/src/renderer/src/persist/webFolder.ts index a9d020ae..a5fbddbf 100644 --- a/src/renderer/src/persist/webFolder.ts +++ b/apps/studio/src/renderer/src/persist/webFolder.ts @@ -10,7 +10,7 @@ // The read/write helpers are handle-driven and side-effect-isolated so they can // be unit-tested against an in-memory fake directory handle. -import type { FolderFiles } from './mdFolder' +import type { FolderFiles } from '@radical/common/formats/mdFolder' // Minimal structural typings for the File System Access API (avoids depending // on lib.dom variants that may not ship these definitions). diff --git a/apps/studio/src/renderer/src/persistence/autosave.ts b/apps/studio/src/renderer/src/persistence/autosave.ts new file mode 100644 index 00000000..489cd7a6 --- /dev/null +++ b/apps/studio/src/renderer/src/persistence/autosave.ts @@ -0,0 +1,234 @@ +// ─── Studio document persistence ───────────────────────────────────────────── +// +// Keeps the diagram store and Studio's documents (localStorage, files, markdown +// folders) in sync: saves edits to the active document, loads it on boot and +// when the user switches documents, flushes on page hide, and follows the file +// the host opened Studio with. Imported for its side effects, before anything +// else, by main.tsx (and by the test setup). +// +// The diagram store itself has no notion of documents; see +// store/documentBackend.ts for the read side it needs while building its +// initial state, configured by ./configureDocuments. + +import './configureDocuments' // must run before the store module is evaluated +import type { C4Node, C4Relation, DiagramData, DiagramView, NodePosition } from '@radical/common/c4' +import { builtInGovernanceMetamodel } from '@radical/common/metamodel' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import { documents, useDocumentsStore } from '../store/documentStore' +import { host } from '../platform/host' + +// ─── Auto-persist to the active document ──────────────────────────────────── +// Subscribe to model slices and debounce-save into whatever the current +// active document is (LS or FS, resolved fresh on every flush). +if (typeof window !== 'undefined') { + let _persistTimer: ReturnType | null = null + let _suspended = false + // When a structural presentation edit (add/remove/rename/reorder/link a + // slide) happens in viewer/presenter mode, we still must persist it — but + // WITHOUT writing the ephemeral explored model layout to disk. This flag + // tells the next flush to rebuild the model slice from the pre-mode-layout + // snapshot (captured by setAppMode) so only the presentation change lands. + let _layoutSafePending = false + const buildPersistData = (): DiagramData => { + const data = useDiagramStore.getState().saveDiagram() + // In explore mode, override the model slice with the layout snapshot taken + // when we left designer, so ephemeral drags/collapses don't reach disk. + const pre = (window as any).__preModeLayout as + | { c4Nodes: Record; c4Relations: Record; views: Record; defaultPositions: Record } + | undefined + if (_layoutSafePending && pre) { + data.nodes = Object.values(pre.c4Nodes) + data.relations = Object.values(pre.c4Relations) + data.views = Object.values(pre.views) + data.defaultPositions = pre.defaultPositions + } + return data + } + const flushPersist = async (): Promise => { + if (_suspended) return + const activeId = documents.getActiveId() + if (!activeId) return + try { + const data = buildPersistData() + _layoutSafePending = false + await documents.saveDocument(activeId, data) + } catch (e) { + console.warn('[diagramStore] persist failed:', e) + } + } + const schedulePersist = (): void => { + if (_persistTimer !== null) clearTimeout(_persistTimer) + _persistTimer = setTimeout(() => { + _persistTimer = null + void flushPersist() + }, 400) + } + + let prev = useDiagramStore.getState() + useDiagramStore.subscribe((s) => { + // In viewer/presenter ("explore" mode), drags / collapses mutate the + // model temporarily but must NEVER reach disk. The setAppMode snapshot + // restores everything on the way back to designer, so by simply not + // scheduling a persist while we're outside designer, the explore + // session stays fully ephemeral. + const isExploreMode = s.appMode !== 'designer' && s.appMode !== 'metamodel' + const modelChanged = + s.c4Nodes !== prev.c4Nodes || + s.c4Relations !== prev.c4Relations || + s.sequences !== prev.sequences || + s.views !== prev.views || + s.defaultPositions !== prev.defaultPositions || + s.defaultViewport !== prev.defaultViewport || + s.snapshots !== prev.snapshots || + s.presentations !== prev.presentations || + s.metamodel !== prev.metamodel + // Presentation slide management (add/remove/rename/reorder/link) is a + // genuine, durable edit even though the slides dock lives in presenter + // mode. Persist those changes too — but layout-safe, so the ephemeral + // explored model layout is not written alongside them. + const presChangedInExplore = + isExploreMode && s.presentations !== prev.presentations + if (!isExploreMode && modelChanged) { + prev = s + schedulePersist() + } else if (presChangedInExplore) { + prev = s + _layoutSafePending = true + schedulePersist() + } else { + prev = s + } + }) + + // ── Hydrate FS-backed active doc on boot ───────────────────────────────── + // We rendered the sample synchronously above; if the active doc is FS, + // load the file now and replace the in-memory model. We suspend the + // auto-persist while doing this so the "sample → loaded" replacement + // doesn't overwrite the file with stale data. + const activeId = documents.getActiveId() + if (activeId) { + const meta = documents.listDocuments().find(d => d.id === activeId) + if (meta?.source === 'fs' || meta?.source === 'md') { + _suspended = true + documents.loadDocument(activeId).then((data) => { + if (data) { + useDiagramStore.getState().loadDiagram(data) + } else { + // New/empty file: initialize with the maximum built-in metamodel + useDiagramStore.getState().loadDiagram({ + nodes: [], + relations: [], + metamodel: builtInGovernanceMetamodel(), + }) + } + }).catch((e) => console.warn('[diagramStore] FS hydrate failed:', e)) + .finally(() => { _suspended = false }) + } + } + + // ── React to active-document switches ──────────────────────────────────── + // When the user picks a different document in the manager modal, load it. + let prevActive = documents.getActiveId() + useDocumentsStore.subscribe((s) => { + if (s.activeId === prevActive) return + prevActive = s.activeId + if (!s.activeId) return + _suspended = true + const switchMeta = documents.listDocuments().find(d => d.id === s.activeId) + documents.loadDocument(s.activeId).then((data) => { + if (data) { + useDiagramStore.getState().loadDiagram(data) + } else if (switchMeta?.source === 'fs' || switchMeta?.source === 'md') { + // New/empty file: initialize with the maximum built-in metamodel + useDiagramStore.getState().loadDiagram({ + nodes: [], + relations: [], + metamodel: builtInGovernanceMetamodel(), + }) + } + }).catch((e) => console.warn('[diagramStore] switch-doc load failed:', e)) + .finally(() => { _suspended = false }) + }) + + // Expose hooks for other modules / future use. + ;(window as any).__radicalFlushPersist = flushPersist + + // ── Synchronous flush on tab close / reload / hide ─────────────────────── + // LocalStorage writes are synchronous, so we can safely persist pending + // changes during pagehide / visibilitychange. This prevents a quick + // reload (within the 400 ms debounce) from losing the most recent edit + // (e.g. a freshly-applied smart layout). + const flushSync = (): void => { + if (_suspended) return + if (_persistTimer === null) return // nothing pending + clearTimeout(_persistTimer) + _persistTimer = null + const activeId = documents.getActiveId() + if (!activeId) return + const meta = documents.listDocuments().find(d => d.id === activeId) + // FS writes go through the async host bridge and won't reliably complete + // on pagehide; LS writes are synchronous and always make it to disk. + if (meta?.source !== 'ls') { + // Best effort — fire-and-forget. Pending FS save may still complete + // if the unload races slowly enough; if not, the user will see the + // pre-edit state next time. + void flushPersist() + return + } + try { + const data = buildPersistData() + _layoutSafePending = false + void documents.saveDocument(activeId, data) + } catch (e) { + console.warn('[diagramStore] sync flush failed:', e) + } + } + if (typeof window.addEventListener === 'function') { + window.addEventListener('pagehide', flushSync) + window.addEventListener('beforeunload', flushSync) + } + if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') flushSync() + }) + } + + // ── CLI-specified watched file ──────────────────────────────────────────── + // When Radical.Tools is launched with --file /path/to/model.c4.json (or the + // RADICAL_FILE env var), Electron's main process watches the file and pushes + // a 'file:external-change' IPC message whenever it changes on disk. + // + // 1. On boot: ensure the file is registered as an FS-backed doc and + // activate it. The existing switch-doc subscriber (above) will load it. + // 2. On external change: suspend auto-persist, reload the model from the + // new content, then re-enable persist. This lets the user edit the file + // in any external editor and see changes reflected in real-time. + // + // Writing back from the app is handled by the existing auto-persist path + // (saveDocument → file:write IPC), which already targets FS-backed docs. + const h = host() + if (h.getWatchedPath) { + void h.getWatchedPath().then((watchedPath) => { + if (!watchedPath) return + const meta = documents.createFSDocument(watchedPath) + // Activate (triggers the switch-doc subscriber above → loads the file). + documents.setActiveId(meta.id) + }) + } + + if (h.onFileChanged) { + h.onFileChanged(({ content }) => { + _suspended = true + try { + const data = JSON.parse(content) as DiagramData + useDiagramStore.getState().loadDiagram(data) + } catch (e) { + console.warn('[diagramStore] external file change — invalid JSON, ignored:', e) + } finally { + // Brief delay so the store's _sync() and React render cycle can + // complete before auto-persist is re-enabled. + setTimeout(() => { _suspended = false }, 300) + } + }) + } +} diff --git a/apps/studio/src/renderer/src/persistence/configureDocuments.ts b/apps/studio/src/renderer/src/persistence/configureDocuments.ts new file mode 100644 index 00000000..da9de2f8 --- /dev/null +++ b/apps/studio/src/renderer/src/persistence/configureDocuments.ts @@ -0,0 +1,15 @@ +// Gives the diagram store Studio's documents to start from. Must be evaluated +// before the store module, so it imports nothing that imports the store. + +import { configureDocumentBackend } from '@radical/ui/store/documentBackend' +import { documents, readLocalDocument } from '../store/documentStore' + +configureDocumentBackend({ + openActive(seed) { + const { meta } = documents.ensureActive(seed) + return meta.source === 'ls' ? readLocalDocument(meta.id) : null + }, + getActiveId: () => documents.getActiveId(), + hydrateNodeBody: (docId, nodeId) => documents.hydrateNodeBody(docId, nodeId), + getPendingBodyNodeIds: (docId) => documents.getPendingBodyNodeIds(docId), +}) diff --git a/apps/studio/src/renderer/src/platform/host.ts b/apps/studio/src/renderer/src/platform/host.ts new file mode 100644 index 00000000..8f3c5adc --- /dev/null +++ b/apps/studio/src/renderer/src/platform/host.ts @@ -0,0 +1,42 @@ +// ─── Host detection ────────────────────────────────────────────────────────── +// +// The one place that knows how Studio finds its host. Everything else calls +// host() and checks for the capability it needs (see @radical/host-bridge). +// +// Electron the preload script exposes `window.electronAPI` +// VS Code the webview provides acquireVsCodeApi(); apps/vscode injects the +// boot data (the file being edited) +// browser neither: WEB_HOST, no native capabilities +// +// Resolved on every call rather than once at import, so tests can install a +// fake Electron API after the stores have loaded. The VS Code host is cached: +// acquireVsCodeApi() may only be called once per webview. + +import { WEB_HOST, type HostBridge, type HostCapabilities } from '@radical/host-bridge' +import { + VSCODE_BOOT_GLOBAL, + createVsCodeWebviewHost, + type MessageTarget, + type VsCodeApi, + type VsCodeBoot, +} from '@radical/host-bridge/vscode' + +interface HostGlobals { + electronAPI?: HostCapabilities + acquireVsCodeApi?: () => VsCodeApi + [VSCODE_BOOT_GLOBAL]?: VsCodeBoot +} + +let vscodeHost: HostBridge | undefined + +export function host(): HostBridge { + const g = globalThis as unknown as HostGlobals + if (g.electronAPI) return { ...g.electronAPI, kind: 'electron' } + if (vscodeHost) return vscodeHost + if (typeof g.acquireVsCodeApi === 'function') { + const boot = g[VSCODE_BOOT_GLOBAL] ?? { filePath: null } + vscodeHost = createVsCodeWebviewHost(g.acquireVsCodeApi(), boot, globalThis as unknown as MessageTarget) + return vscodeHost + } + return WEB_HOST +} diff --git a/src/renderer/src/route.ts b/apps/studio/src/renderer/src/route.ts similarity index 99% rename from src/renderer/src/route.ts rename to apps/studio/src/renderer/src/route.ts index 6a5372c8..794c38bc 100644 --- a/src/renderer/src/route.ts +++ b/apps/studio/src/renderer/src/route.ts @@ -22,7 +22,7 @@ // a presentation is actually running. import { useEffect } from 'react' -import { useDiagramStore } from './store/diagramStore' +import { useDiagramStore } from '@radical/ui/store/diagramStore' import { documents, useDocumentsStore } from './store/documentStore' type Mode = 'designer' | 'viewer' | 'presenter' | 'metamodel' diff --git a/src/renderer/src/store/documentStore.ts b/apps/studio/src/renderer/src/store/documentStore.ts similarity index 93% rename from src/renderer/src/store/documentStore.ts rename to apps/studio/src/renderer/src/store/documentStore.ts index 9fe2b4da..562ae5be 100644 --- a/src/renderer/src/store/documentStore.ts +++ b/apps/studio/src/renderer/src/store/documentStore.ts @@ -9,12 +9,13 @@ // (plain functions + zustand store) so the diagram store can wire into it. import { create } from 'zustand' -import type { DiagramData, C4Node } from '../types/c4' +import type { DiagramData, C4Node } from '@radical/common/c4' +import { host } from '../platform/host' import { serializeToMdFolder, deserializeFromMdFolder, extractNodeBody, -} from '../persist/mdFolder' +} from '@radical/common/formats/mdFolder' import { webFolderSupported, pickWebDirectory, @@ -32,7 +33,7 @@ const LS_DOC_PREFIX = 'radical-doc:' /** Legacy single-slot key from the previous persistence iteration. */ const LS_LEGACY_KEY = 'radical-diagram-v1' -/** Web md-folder docs (source==='md', no electronAPI) whose directory handle +/** Web md-folder docs (source==='md', no native host folder access) whose directory handle * has verified read/write permission this session. Writes are skipped until a * folder is "connected" so a permission-pending handle can never clobber the * user's files with an empty/stale model after a reload. */ @@ -98,6 +99,11 @@ function lsKeyFor(id: string): string { return LS_DOC_PREFIX + id } +/** The stored content of a localStorage document, read synchronously. */ +export function readLocalDocument(id: string): DiagramData | null { + return readLSPayload(id) +} + function readLSPayload(id: string): DiagramData | null { if (typeof localStorage === 'undefined') return null try { @@ -137,17 +143,18 @@ function defaultNameFromFolder(folderPath: string): string { /** Fetch one md-folder node's body from disk (Electron file or web handle), * without caching it anywhere — the caller decides what to do with it. */ async function fetchNodeBody(id: string, nodeId: string): Promise { + const h = host() const meta = readIndex().docs.find(d => d.id === id) if (!meta) return undefined const relPath = mdBodyPaths.get(id)?.[nodeId] if (!relPath) return undefined try { - if (meta.folderPath && window.electronAPI?.readFile) { - const res = await window.electronAPI.readFile(`${meta.folderPath}/${relPath}`) + if (meta.folderPath && h.readFile) { + const res = await h.readFile(`${meta.folderPath}/${relPath}`) if (!res.success || res.content === undefined) return undefined return extractNodeBody(res.content) } - if (!window.electronAPI?.readFile && webFolderSupported()) { + if (!h.readFile && webFolderSupported()) { const handle = await loadHandle(id) if (!handle) return undefined const content = await readOneFileFromHandle(handle, relPath) @@ -438,16 +445,17 @@ export const documents: DocumentsAPI = { }, async loadDocument(id) { + const h = host() const meta = readIndex().docs.find(d => d.id === id) if (!meta) return null if (meta.source === 'ls') return readLSPayload(id) - if (meta.source === 'fs' && meta.filePath && window.electronAPI?.readFile) { - const res = await window.electronAPI.readFile(meta.filePath) + if (meta.source === 'fs' && meta.filePath && h.readFile) { + const res = await h.readFile(meta.filePath) if (!res.success || !res.content) return null try { return JSON.parse(res.content) as DiagramData } catch { return null } } - if (meta.source === 'md' && meta.folderPath && window.electronAPI?.readFolder) { - const res = await window.electronAPI.readFolder(meta.folderPath) + if (meta.source === 'md' && meta.folderPath && h.readFolder) { + const res = await h.readFolder(meta.folderPath) if (!res.success || !res.files) return null if (Object.keys(res.files).length === 0) return null // empty/new folder try { @@ -456,7 +464,7 @@ export const documents: DocumentsAPI = { return data } catch { return null } } - if (meta.source === 'md' && !window.electronAPI?.readFolder && webFolderSupported()) { + if (meta.source === 'md' && !h.readFolder && webFolderSupported()) { const handle = await loadHandle(id) if (!handle) return null if (!(await verifyPermission(handle, 'readwrite', false))) return null // needs reconnect @@ -481,27 +489,28 @@ export const documents: DocumentsAPI = { }, async saveDocument(id, data) { + const h = host() const idx = readIndex() const meta = idx.docs.find(d => d.id === id) if (!meta) return if (meta.source === 'ls') { writeLSPayload(id, data) - } else if (meta.source === 'fs' && meta.filePath && window.electronAPI?.writeFile) { + } else if (meta.source === 'fs' && meta.filePath && h.writeFile) { const json = JSON.stringify(data, null, 2) - const res = await window.electronAPI.writeFile(meta.filePath, json) + const res = await h.writeFile(meta.filePath, json) if (!res.success) { console.warn('[documentStore] FS write failed for', meta.filePath, res.error) return } - } else if (meta.source === 'md' && meta.folderPath && window.electronAPI?.writeFolder) { + } else if (meta.source === 'md' && meta.folderPath && h.writeFolder) { const nodes = await nodesForMdSave(id, data) const files = serializeToMdFolder({ ...data, nodes }, meta.name) - const res = await window.electronAPI.writeFolder(meta.folderPath, files) + const res = await h.writeFolder(meta.folderPath, files) if (!res.success) { console.warn('[documentStore] folder write failed for', meta.folderPath, res.error) return } - } else if (meta.source === 'md' && !window.electronAPI?.writeFolder && webFolderSupported()) { + } else if (meta.source === 'md' && !h.writeFolder && webFolderSupported()) { // Never write until the handle's permission is verified this session, // so a reload with a permission-pending handle can't overwrite files. if (!connectedWebFolders.has(id)) { @@ -552,10 +561,11 @@ export const documents: DocumentsAPI = { }, async importFromFile(pickerOverride) { + const h = host() // ── Electron path: native open dialog returns an absolute filePath we // can read/write through preload IPC. We register an FS-backed doc. - if (typeof window !== 'undefined' && window.electronAPI?.openDiagram) { - const res = await window.electronAPI.openDiagram() + if (h.openDiagram) { + const res = await h.openDiagram() if (!res.success || !res.filePath) return null const idx = readIndex() // De-dupe by path: if we already track this file, just activate it. @@ -604,12 +614,13 @@ export const documents: DocumentsAPI = { }, async saveAsFile(id, data, downloadOverride) { + const h = host() const json = JSON.stringify(data, null, 2) // ── Electron path: native Save dialog returns an absolute path; we // convert the doc into FS-backed and drop any LS payload. - if (typeof window !== 'undefined' && window.electronAPI?.saveDiagram) { - const res = await window.electronAPI.saveDiagram(json) + if (h.saveDiagram) { + const res = await h.saveDiagram(json) if (!res.success || !res.filePath) return null const idx = readIndex() const meta = idx.docs.find(d => d.id === id) @@ -646,9 +657,10 @@ export const documents: DocumentsAPI = { }, async importFromFolder() { + const h = host() // ── Electron: native directory dialog → read via IPC. ── - if (typeof window !== 'undefined' && window.electronAPI?.openFolder) { - const res = await window.electronAPI.openFolder() + if (h.openFolder) { + const res = await h.openFolder() if (!res.success || !res.folderPath) return null const idx = readIndex() const existing = idx.docs.find((d) => d.source === 'md' && d.folderPath === res.folderPath) @@ -696,16 +708,17 @@ export const documents: DocumentsAPI = { }, async saveAsFolder(id, data) { + const h = host() // ── Electron: pick a destination folder, write via IPC. ── - if (typeof window !== 'undefined' && window.electronAPI?.pickFolder && window.electronAPI?.writeFolder) { + if (h.pickFolder && h.writeFolder) { const idx = readIndex() const meta = idx.docs.find((d) => d.id === id) if (!meta) return null - const picked = await window.electronAPI.pickFolder() + const picked = await h.pickFolder() if (!picked.success || !picked.folderPath) return null const name = defaultNameFromFolder(picked.folderPath) const files = serializeToMdFolder(data, name) - const res = await window.electronAPI.writeFolder(picked.folderPath, files) + const res = await h.writeFolder(picked.folderPath, files) if (!res.success) { console.warn('[documentStore] saveAsFolder write failed:', res.error) return null diff --git a/src/renderer/src/store/fintechSample.ts b/apps/studio/src/renderer/src/store/fintechSample.ts similarity index 96% rename from src/renderer/src/store/fintechSample.ts rename to apps/studio/src/renderer/src/store/fintechSample.ts index b9869224..4a7fbd27 100644 --- a/src/renderer/src/store/fintechSample.ts +++ b/apps/studio/src/renderer/src/store/fintechSample.ts @@ -20,10 +20,10 @@ import type { DiagramData, C4Node, C4Relation, DiagramSequence, DiagramSnapshot, DiagramView, NodePosition, Presentation, -} from '../types/c4' -import { COLLAPSED_HEIGHT, COLLAPSED_WIDTH } from '../types/c4' -import { builtInGovernanceMetamodel } from '../types/metamodel' -import { runSmartLayout } from '../layout/smartLayoutRunner' +} from '@radical/common/c4' +import { COLLAPSED_HEIGHT, COLLAPSED_WIDTH } from '@radical/common/c4' +import { builtInGovernanceMetamodel } from '@radical/common/metamodel' +import { runSmartLayout } from '@radical/ui/layout/smartLayoutRunner' import savedSampleData from './fintechSampleData.json' // ── helpers ────────────────────────────────────────────────────────────────── @@ -434,26 +434,6 @@ function _buildPresentations(): Presentation[] { }] } -/** - * Reads the developer override file from disk at runtime via the dedicated - * `dev:loadSample` IPC channel (bypasses the static Vite bundle cache). - * Falls back to buildFintechSampleRaw() when no override is present or when - * running outside Electron dev mode. - */ -export async function loadFintechSample(): Promise { - const api = (window as { electronAPI?: { devLoadSample?: () => Promise<{ success: boolean; content?: string }> } }).electronAPI - if (api?.devLoadSample) { - try { - const res = await api.devLoadSample() - if (res.success && res.content) { - const parsed = JSON.parse(res.content) as { nodes?: unknown } - if (parsed.nodes) return parsed as DiagramData - } - } catch { /* fall through */ } - } - return buildFintechSampleRaw() -} - /** * Synchronous build — hardcoded initial positions, no smart layout. * Used as a fallback when no saved layout is present in fintechSampleData.json. diff --git a/src/renderer/src/store/fintechSampleData.json b/apps/studio/src/renderer/src/store/fintechSampleData.json similarity index 100% rename from src/renderer/src/store/fintechSampleData.json rename to apps/studio/src/renderer/src/store/fintechSampleData.json diff --git a/apps/studio/src/renderer/src/studio.css b/apps/studio/src/renderer/src/studio.css new file mode 100644 index 00000000..e97d1ecf --- /dev/null +++ b/apps/studio/src/renderer/src/studio.css @@ -0,0 +1,2892 @@ +/* Styles for Studio-only UI (toolbar, document manager, Forge, extra views, + modals). Shared styles live in @radical/ui/styles.css, loaded first. */ + +/* ─── App Layout ────────────────────────────────────────────────────── */ +.app-layout { + display: grid; + grid-template-rows: 48px 1fr auto; + grid-template-columns: 220px 1fr 260px; + grid-template-areas: + "toolbar toolbar toolbar" + "leftpanel canvas rightpanel" + "timetravel timetravel timetravel"; + height: 100vh; + transition: grid-template-columns 0.22s cubic-bezier(0.4, 0, 0.2, 1); +} +/* Collapsed panel rails */ +.app-layout.lp-collapsed { grid-template-columns: 18px 1fr 260px; } +/* Presentation mode — same 3-column layout as modelling (left panel + canvas + right panel) */ +.app-layout.mode-presentation { + grid-template-columns: 240px 1fr 260px; + grid-template-rows: 48px 1fr auto; + grid-template-areas: + "toolbar toolbar toolbar" + "leftpanel canvas rightpanel" + "timetravel timetravel timetravel"; +} +.app-layout.mode-presentation.lp-collapsed { grid-template-columns: 18px 1fr 260px; } +/* Active presenting — canvas only, HUD is a fixed overlay */ +.app-layout.mode-presenting { + grid-template-columns: 1fr; + grid-template-rows: 1fr; + grid-template-areas: "canvas"; +} +/* Metamodel mode — full-width editor takes the canvas area */ +.app-layout.mode-metamodel { + grid-template-columns: 1fr; + grid-template-rows: 48px 1fr; + grid-template-areas: + "toolbar" + "canvas"; +} +/* ─── Presenter Dock (bottom slides bar in presenter mode) ─────────── */ +.pres-dock { + grid-area: timetravel; + display: flex; + align-items: stretch; + background: var(--bg-toolbar); + border-top: 1px solid var(--border-color); + box-shadow: 0 -2px 16px rgba(0,0,0,0.10); + height: 110px; + overflow: hidden; +} +.pres-dock-controls { + flex-shrink: 0; + width: 240px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 6px; + padding: 8px 10px; + border-right: 1px solid var(--border-color); + background: var(--bg-toolbar); +} +.pres-dock-controls .pres-picker { + padding: 0; + border-bottom: none; +} +.pres-dock-actions { + display: flex; + gap: 6px; +} +.pres-dock-track { + flex: 1; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} +.pres-dock-card-wrap { + flex-shrink: 0; + min-width: 220px; + max-width: 280px; +} +.pres-dock-card-wrap .pres-slide-card { + height: 100%; +} +.pres-dock-empty { + padding: 12px 16px; + white-space: nowrap; +} +/* ─── App brand (logo + menu trigger combo) ─────────────────────────── */ +.app-brand { + display: inline-flex; + align-items: center; + gap: 8px; + height: 30px; + padding: 0 10px 0 8px; + margin-right: 4px; + background: none; + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; + color: var(--text-primary); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.01em; + transition: background 0.15s ease, border-color 0.15s ease; +} +.app-brand-logo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 6px; + color: var(--accent); + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.18), rgba(var(--accent-rgb), 0.06)); +} +.app-brand-name { + background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, var(--text-primary))); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} +.app-brand-caret { + display: inline-flex; + color: var(--text-muted); + transition: transform 0.18s ease, color 0.15s ease; +} +/* ─── App menu (hamburger popover) ──────────────────────────────────── */ +.app-menu-wrap { position: relative; display: inline-flex; } +.app-menu-popover { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 1000; + min-width: 240px; + background: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: 10px; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.15), + 0 8px 24px rgba(0, 0, 0, 0.28); + padding: 6px; + animation: app-menu-in 120ms ease-out; + transform-origin: top left; +} +.app-menu-section { display: flex; flex-direction: column; gap: 1px; padding: 2px 0; } +.app-menu-label { + padding: 4px 10px 2px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} +.app-menu-divider { + height: 1px; + background: var(--border-color); + margin: 4px 2px; + opacity: 0.7; +} +.app-menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 7px 10px; + background: none; + border: none; + border-radius: 6px; + cursor: pointer; + color: var(--text-primary); + font-size: 12.5px; + text-align: left; + transition: background 0.12s ease, color 0.12s ease; +} +.app-menu-item:hover { background: var(--hover-bg); } +.app-menu-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + color: var(--text-muted); + flex-shrink: 0; +} +.app-menu-item:hover .app-menu-icon { color: var(--text-primary); } +.app-menu-icon svg { width: 14px; height: 14px; } +.app-menu-text { flex: 1; } +.app-menu-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 6px 10px; +} +.app-menu-row-label { + font-size: 12px; + color: var(--text-secondary); +} +.app-menu-select { + background: var(--input-bg); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 4px 8px; + font-size: 11.5px; + cursor: pointer; + outline: none; + transition: border-color 0.12s ease; +} +.app-menu-select:hover, +.app-menu-select:focus { border-color: var(--accent); } +/* Presentation picker */ +.pres-picker { + padding: 6px 8px; + border-bottom: 1px solid var(--border-color); +} +.pres-picker-row { + display: flex; + align-items: center; + gap: 4px; +} +.pres-picker-select, +.pres-picker-input { + flex: 1; + min-width: 0; + padding: 4px 6px; + font-size: 12px; + background: var(--bg-input, var(--bg-toolbar)); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + outline: none; +} +.pres-picker-select:focus, +.pres-picker-input:focus { + border-color: var(--accent, #3b82f6); +} +.pres-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + color: var(--text-secondary, var(--text-primary)); + cursor: pointer; + font-size: 13px; + line-height: 1; +} +.pres-icon-btn:hover { + background: var(--bg-hover, rgba(255,255,255,0.06)); + border-color: var(--border-color); + color: var(--text-primary); +} +.pres-panel-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border: 1px solid var(--border-color); + border-radius: 5px; + background: transparent; + color: var(--text-primary); + font-size: 12px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + white-space: nowrap; +} +.pres-panel-btn svg { width: 13px; height: 13px; } +.pres-panel-btn:hover:not(:disabled) { background: var(--hover-bg); border-color: var(--accent); } +.pres-panel-btn:disabled { opacity: 0.4; cursor: default; } +/* Slide list */ +.pres-empty { + padding: 20px 12px; + color: var(--text-muted); + font-size: 12px; + text-align: center; + line-height: 1.6; +} +.pres-slide-card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 7px; + border: 1px solid transparent; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; + background: transparent; +} +.pres-slide-card:hover { background: var(--hover-bg); border-color: var(--border-color); } +.pres-slide-num { + width: 22px; + height: 22px; + border-radius: 4px; + background: var(--border-color); + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.pres-slide-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.pres-slide-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.pres-slide-name-input { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + background: var(--bg-canvas); + border: 1px solid var(--accent); + border-radius: 3px; + padding: 1px 6px; + outline: none; + width: 100%; +} +.pres-slide-snap-tag { + font-size: 10px; + color: var(--accent); + background: rgba(var(--accent-rgb, 99,102,241), 0.15); + border-radius: 3px; + padding: 1px 5px; + align-self: flex-start; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} +.pres-slide-view-tag { + font-size: 10px; + color: var(--success, #22c55e); + background: rgba(34, 197, 94, 0.15); + border-radius: 3px; + padding: 1px 5px; + align-self: flex-start; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} +.pres-slide-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.12s; +} +.pres-slide-card:hover .pres-slide-actions { opacity: 1; } +.pres-icon-btn { + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: none; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; + transition: background 0.12s, color 0.12s; +} +.pres-icon-btn svg { width: 14px; height: 14px; } +.pres-icon-btn:hover { background: var(--hover-bg); color: var(--text-primary); } +/* Snapshot picker */ +.pres-snap-picker { + position: absolute; + bottom: calc(100% + 4px); + right: 0; + z-index: 500; + background: var(--bg-toolbar); + border: 1px solid var(--border-color); + border-radius: 6px; + box-shadow: 0 8px 32px rgba(0,0,0,0.22); + min-width: 160px; + overflow: hidden; +} +.pres-snap-picker-title { + padding: 6px 10px 4px; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.pres-snap-item { + display: block; + width: 100%; + text-align: left; + padding: 6px 10px; + font-size: 12px; + border: none; + background: none; + color: var(--text-primary); + cursor: pointer; + transition: background 0.1s; +} +.pres-snap-item:hover { background: var(--hover-bg); } +/* Presenter HUD (bottom bar during presentation) */ +.pres-hud { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 400; + display: flex; + align-items: center; + gap: 10px; + background: rgba(15, 15, 20, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 32px; + padding: 8px 16px; + box-shadow: 0 8px 40px rgba(0,0,0,0.4); +} +.pres-hud-btn { + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(255,255,255,0.15); + border-radius: 8px; + background: transparent; + color: rgba(255,255,255,0.8); + cursor: pointer; + transition: background 0.12s, color 0.12s; + flex-shrink: 0; +} +.pres-hud-btn svg { width: 16px; height: 16px; } +.pres-hud-btn:disabled { opacity: 0.3; cursor: default; } +.pres-hud-btn:hover:not(:disabled) { + background: rgba(255,255,255,0.1); + color: #fff; +} +.pres-hud-dots { + display: flex; + align-items: center; + gap: 5px; + padding: 0 4px; +} +.pres-hud-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(255,255,255,0.25); + border: none; + cursor: pointer; + transition: background 0.15s, transform 0.15s; + padding: 0; +} +.pres-hud-label { + display: flex; + flex-direction: column; + align-items: center; + min-width: 80px; + gap: 1px; +} +.pres-hud-label strong { + font-size: 13px; + font-weight: 600; + color: #fff; + white-space: nowrap; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; +} +.pres-hud-counter { + font-size: 11px; + color: rgba(255,255,255,0.5); +} +/* ── Milestone editing banner + modal ────────────────────────────── */ +.milestone-btn { + padding: 4px 10px; + font-size: 11px; + border-radius: 4px; + border: 1px solid var(--border-color); + background: var(--bg-toolbar); + color: var(--text-primary); + cursor: pointer; +} +.milestone-btn:hover { background: var(--bg-hover, rgba(255,255,255,0.06)); } +.milestone-option-input { + margin-top: 8px; + width: 100%; + padding: 5px 8px; + font-size: 12px; + background: var(--bg-input, var(--bg-toolbar)); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + outline: none; +} +.milestone-option-input:focus { border-color: var(--accent, #3b82f6); } +.milestone-modal-footer { + margin-top: 14px; + display: flex; + justify-content: flex-end; +} +/* ─── Radical Forge — drops down from the top-center search bar, same + family as .quick-search-dropdown's own pop-open treatment. No backdrop: + canvas stays visible and interactive around it, closing is via ✕/Escape. */ +.forge-panel { + position: fixed; + top: 52px; + left: 50%; + transform: translateX(-50%); + width: min(640px, 90vw); + max-height: min(78vh, 720px); + z-index: 4000; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 20px 22px; + background: color-mix(in srgb, var(--bg-panel) 97%, transparent); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 18px 48px rgba(0,0,0,0.45), 0 4px 10px rgba(0,0,0,0.22); + backdrop-filter: blur(16px) saturate(140%); + -webkit-backdrop-filter: blur(16px) saturate(140%); + animation: forge-panel-in 160ms cubic-bezier(0.22, 1, 0.36, 1); +} +.forge-panel::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 3px; + background: linear-gradient(90deg, var(--accent), #9b5de5); +} +.forge-title-row { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 6px; + /* Reserves room for the absolutely-positioned .ai-settings-close button + (right: 10px, width: 28px) so a wide token count (e.g. "25.9K tokens") + doesn't run underneath it. */ + padding-right: 32px; +} +.forge-title-icon { + display: inline-flex; + color: #9b5de5; + flex-shrink: 0; +} +.forge-session-usage { + margin-left: auto; + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + background: var(--bg-toolbar, rgba(255,255,255,0.05)); + border: 1px solid var(--border-color); + border-radius: 999px; + padding: 2px 8px; + cursor: default; +} +@media (max-width: 720px) { + .forge-panel { width: 94vw; top: 46px; } +} +.forge-steps { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 4px 0 10px; +} +.forge-step { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10.5px; + font-weight: 600; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid var(--border-color); + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: background-color .14s, border-color .14s, color .14s, box-shadow .14s; +} +.forge-step:disabled { cursor: default; opacity: 0.4; } +.forge-step-check { font-size: 9px; line-height: 1; } +.forge-warning { + font-size: 11.5px; + color: #ffb057; + background: color-mix(in srgb, #ffb057 12%, transparent); + border: 1px solid color-mix(in srgb, #ffb057 35%, transparent); + border-radius: 6px; + padding: 6px 10px; + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.forge-body { + flex: 1 1 auto; + overflow-y: auto; + min-height: 0; +} +.forge-textarea { + width: 100%; + resize: vertical; + padding: 8px 10px; + font: inherit; + font-size: 12.5px; + line-height: 1.5; + background: var(--bg-input, var(--bg-toolbar)); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + outline: none; +} +.forge-textarea:focus { border-color: var(--accent, #3b82f6); } +.forge-stage-card { + padding: 2px 0; +} +.forge-wireframes { + margin-top: 12px; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 6px; + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} +.forge-wireframes-title { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); +} +.forge-wireframes-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + font-size: 12px; + color: var(--text-secondary, var(--text-muted)); +} +.forge-stage-result { margin-top: 4px; } +.forge-finish-summary { + list-style: none; + margin: 0 0 14px; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 6px; +} +.forge-finish-summary li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 6px 12px; + font-size: 12px; +} +.forge-finish-summary li + li { border-top: 1px solid var(--border-color); } +.forge-finish-stage { color: var(--text-primary); font-weight: 600; } +.forge-finish-result { color: var(--text-muted); font-variant-numeric: tabular-nums; } +.forge-finish-skipped .forge-finish-stage { color: var(--text-muted); font-weight: 400; } +.forge-finish-skipped .forge-finish-result { font-style: italic; } +/* ─── Radical Forge — button system ──────────────────────────────────────── */ +.forge-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + font: inherit; + font-size: 12px; + font-weight: 600; + padding: 7px 14px; + border-radius: 7px; + cursor: pointer; + border: 1px solid transparent; + white-space: nowrap; + transition: background-color .14s, border-color .14s, color .14s, box-shadow .14s, filter .14s, transform .06s; +} +.forge-btn:active:not(:disabled) { transform: translateY(1px); } +.forge-btn:disabled { cursor: default; opacity: 0.45; } +.forge-btn-sm { padding: 4px 10px; font-size: 11px; border-radius: 6px; } +.forge-btn-primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); + box-shadow: 0 1px 2px rgba(0,0,0,.18); +} +.forge-btn-primary:hover:not(:disabled) { + filter: brightness(1.08); + box-shadow: 0 3px 10px color-mix(in srgb, var(--accent) 45%, transparent); +} +.forge-btn-primary:disabled { filter: none; box-shadow: none; } +.forge-btn-secondary { + background: transparent; + color: var(--text-secondary, var(--text-primary)); + border-color: var(--border-color); +} +.forge-btn-secondary:hover:not(:disabled) { + color: var(--text-primary); + border-color: var(--text-muted); + background: var(--bg-hover, rgba(255,255,255,0.05)); +} +.forge-btn-ghost { + background: transparent; + color: var(--text-muted); + border-color: transparent; +} +.forge-btn-ghost:hover:not(:disabled) { + color: var(--text-primary); + background: var(--bg-hover, rgba(255,255,255,0.06)); +} +.forge-btn-success { + background: color-mix(in srgb, #16a34a 16%, transparent); + color: #16a34a; + border-color: color-mix(in srgb, #16a34a 40%, transparent); + cursor: default; +} +.forge-hub-suggestions { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; + padding: 8px 10px; + background: var(--bg-toolbar, rgba(255,255,255,0.03)); + border: 1px solid var(--border-color); + border-radius: 8px; +} +.forge-hub-suggestions-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 2px; +} +.forge-hub-card { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 4px; + border-top: 1px solid var(--border-color); +} +.forge-hub-card:first-of-type { border-top: none; } +.forge-hub-card-main { flex: 1; min-width: 0; } +.forge-hub-card-name { font-size: 12px; font-weight: 600; color: var(--text-primary); } +.forge-hub-card-category { + margin-left: 6px; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} +.forge-hub-card-desc { + font-size: 11px; + color: var(--text-secondary, var(--text-muted)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 1px; +} +.forge-clarify { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; + padding: 10px; + background: color-mix(in srgb, var(--accent) 6%, transparent); + border: 1px solid var(--border-color); + border-radius: 8px; +} +.forge-clarify-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} +.forge-clarify-q { display: flex; flex-direction: column; gap: 4px; } +.forge-clarify-question { font-size: 12px; color: var(--text-primary); font-weight: 500; } +.forge-clarify-input { + padding: 6px 9px; + font-size: 12px; + background: var(--bg-input, var(--bg-toolbar)); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + outline: none; + transition: border-color .13s; +} +.forge-clarify-input:focus { border-color: var(--accent, #3b82f6); } +.forge-clarify-options { display: flex; flex-direction: column; gap: 5px; } +.forge-clarify-option { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary, var(--text-primary)); + cursor: pointer; + padding: 6px 10px; + border-radius: 7px; + border: 1px solid var(--border-color); + background: var(--bg-input, transparent); + transition: border-color .13s, background-color .13s, color .13s; +} +.forge-clarify-option:hover { border-color: var(--text-muted); } +.forge-clarify-option input { accent-color: var(--accent); margin: 0; flex-shrink: 0; cursor: pointer; } +.forge-clarify-option:has(input:checked) { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--text-primary); + font-weight: 500; +} +.forge-clarify-summary { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--text-muted); + margin-bottom: 10px; +} +/* ─── Radical Forge — live progress feed ─────────────────────────────────── */ +.forge-progress { + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + background: var(--bg-toolbar, rgba(255,255,255,0.03)); +} +.forge-progress-header { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-bottom: 1px solid var(--border-color); + background: color-mix(in srgb, var(--accent) 8%, transparent); +} +.forge-progress-pulse { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); + animation: forge-progress-pulse 1.1s ease-in-out infinite; + flex-shrink: 0; +} +.forge-progress-round { + font-size: 11px; + font-weight: 600; + color: var(--text-primary); +} +.forge-progress-stat { + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 999px; + background: color-mix(in srgb, #16a34a 18%, transparent); + color: #16a34a; +} +.forge-progress-stat-error { + background: color-mix(in srgb, #ff6b6b 18%, transparent); + color: #ff6b6b; +} +.forge-progress-stat-usage { + background: color-mix(in srgb, #9b5de5 16%, transparent); + color: #9b5de5; +} +.forge-usage-line { + font-size: 10.5px; + color: var(--text-muted); + margin-top: 2px; +} +.forge-progress-list { + display: flex; + flex-direction: column; + max-height: 168px; + overflow-y: auto; + padding: 6px 4px; +} +.forge-progress-entry { + display: flex; + align-items: flex-start; + gap: 7px; + padding: 3px 8px; + font-size: 11.5px; + line-height: 1.4; + color: var(--text-secondary, var(--text-primary)); + animation: forge-progress-in 0.22s ease-out; +} +.forge-progress-entry-text-inner { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.forge-progress-dot { + flex-shrink: 0; + margin-top: 5px; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--text-muted); +} +.forge-progress-entry-action .forge-progress-dot { background: #16a34a; } +.forge-progress-entry-error .forge-progress-dot { background: #ff6b6b; } +.forge-progress-entry-error { color: #ff8888; } +.forge-progress-entry-text .forge-progress-dot { background: var(--accent); } +.forge-progress-entry-text .forge-progress-entry-text-inner { + white-space: normal; + font-style: italic; + color: var(--text-muted); +} +/* ─── Document manager modal ─────────────────────────────────────────────── */ +.docmgr-backdrop { + position: fixed; inset: 0; z-index: 2000; + background: rgba(0, 0, 0, 0.45); + display: flex; align-items: center; justify-content: center; + backdrop-filter: blur(2px); +} +.docmgr-modal { + width: min(720px, 92vw); + max-height: 80vh; + display: flex; flex-direction: column; + background: var(--bg-panel); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + overflow: hidden; +} +.docmgr-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--border-color); +} +.docmgr-header h2 { margin: 0; font-size: 16px; font-weight: 600; color: var(--text-primary); } +.docmgr-close { + background: none; border: none; color: var(--text-secondary); + font-size: 24px; line-height: 1; cursor: pointer; padding: 0 6px; + border-radius: 4px; +} +.docmgr-close:hover { background: var(--hover-bg); color: var(--text-primary); } +.docmgr-tabs { + display: flex; gap: 2px; + padding: 0 12px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-app); +} +.docmgr-tab { + display: inline-flex; align-items: center; gap: 8px; + padding: 10px 14px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-muted); + font: inherit; font-size: 13px; + cursor: pointer; + margin-bottom: -1px; /* overlap container border for active underline */ +} +.docmgr-tab:hover { color: var(--text-primary); background: var(--hover-bg-subtle); } +.docmgr-tab-count { + font-size: 11px; + padding: 1px 6px; + border-radius: 8px; + background: var(--hover-bg); + color: inherit; +} +.docmgr-toolbar { + display: flex; gap: 8px; align-items: center; padding: 12px 18px; + border-bottom: 1px solid var(--border-color); +} +.docmgr-toolbar-create { + flex-direction: column; + align-items: stretch; + gap: 12px; +} +.docmgr-create-row { + display: flex; gap: 8px; align-items: center; +} +.docmgr-create-row .docmgr-rename-input { flex: 1; } +.docmgr-mm-picker { + display: flex; flex-direction: column; gap: 6px; +} +.docmgr-mm-picker-title { + font-size: 12px; color: var(--text-muted); + text-transform: uppercase; letter-spacing: 0.04em; +} +.docmgr-mm-option { + text-align: left; + padding: 10px 12px; + background: var(--input-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + cursor: pointer; + color: inherit; +} +.docmgr-mm-option:hover { + background: var(--hover-bg); + border-color: var(--border-color-strong); +} +.docmgr-mm-option-name { + font-size: 13px; font-weight: 600; color: var(--text-primary); + margin-bottom: 2px; +} +.docmgr-mm-option-desc { + font-size: 12px; color: var(--text-muted); line-height: 1.4; +} +.docmgr-toolbar-hint { + color: var(--text-muted); + font-size: 12px; +} +.docmgr-toolbar-hint code { + background: var(--input-bg); + padding: 0 4px; border-radius: 3px; + font-family: ui-monospace, monospace; font-size: 11px; + color: var(--text-secondary); +} +.docmgr-btn { + padding: 6px 12px; + background: var(--input-bg); + border: 1px solid var(--border-color); + color: var(--text-primary); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 13px; +} +.docmgr-btn:hover { background: var(--hover-bg); border-color: var(--border-color-strong); } +.docmgr-btn-mm { + padding: 0 8px !important; + min-width: unset; + margin-left: -5px; + border-left: 1px solid rgba(127,127,127,.2) !important; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; +} +.docmgr-empty { padding: 32px; text-align: center; color: var(--text-muted); } +.docmgr-list { + list-style: none; margin: 0; padding: 6px 0; + overflow-y: auto; +} +.docmgr-item { + display: flex; align-items: center; gap: 8px; + padding: 10px 18px; + border-bottom: 1px solid var(--border-color); +} +.docmgr-item:hover { background: var(--hover-bg-subtle); } +.docmgr-item-main { + display: flex; align-items: center; gap: 10px; + flex: 1; min-width: 0; cursor: pointer; +} +.docmgr-badge { + font-size: 10px; font-weight: 700; letter-spacing: 0.04em; + padding: 2px 6px; border-radius: 4px; flex-shrink: 0; +} +.docmgr-badge.fs { + background: var(--accent-soft); + color: var(--accent); +} +.docmgr-badge.md { + background: color-mix(in srgb, #16a34a 20%, transparent); + color: #16a34a; +} +.docmgr-item-info { min-width: 0; flex: 1; } +.docmgr-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-primary); } +.docmgr-active-tag { font-weight: 400; color: var(--accent); font-size: 12px; } +.docmgr-sub { + display: flex; gap: 12px; + font-size: 11px; color: var(--text-muted); + margin-top: 2px; +} +.docmgr-path { + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + max-width: 320px; font-family: ui-monospace, monospace; +} +.docmgr-rename-input { + flex: 1; padding: 4px 8px; + background: var(--bg-app); color: var(--text-primary); + border: 1px solid var(--accent); border-radius: 4px; + font: inherit; +} +.docmgr-item-actions { display: flex; gap: 6px; flex-shrink: 0; } +/* ─── Toolbar: active document chip ──────────────────────────────────────── */ +.toolbar-doc-badge { + font-size: 9px; font-weight: 700; letter-spacing: 0.05em; + padding: 1px 5px; border-radius: 3px; +} +.toolbar-doc-badge.fs { background: rgba(79,156,249,0.25); color: #a8c8f8; } +/* Inline current-doc summary inside the "Manage diagrams…" menu item */ +.app-menu-current-doc-inline { + display: flex; align-items: center; gap: 6px; + margin-top: 3px; + font-size: 11px; font-weight: 400; + color: rgba(255,255,255,0.6); +} +.app-menu-current-doc-name { + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + max-width: 180px; +} +/* ─── Metamodel editor ───────────────────────────────────────────────── */ +.mm-editor-modal { + position: relative; + width: min(1200px, 96vw); + max-width: 96vw; + height: min(86vh, 900px); + padding: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.mm-editor-modal-close { + position: absolute; + top: 8px; + right: 10px; + z-index: 2; + background: transparent; + border: none; + color: var(--text-secondary, var(--text-primary)); + font-size: 16px; + line-height: 1; + width: 28px; + height: 28px; + border-radius: 6px; + cursor: pointer; +} +.mm-editor-modal-close:hover { + background: var(--bg-hover, rgba(255,255,255,0.08)); + color: var(--text-primary); +} +.mm-editor-modal .mm-editor { + grid-area: auto; + flex: 1; + min-height: 0; + border-radius: 10px; +} +/* Reserve room in the right column for the absolute-positioned ✕ close + * button so the Issues count badge doesn't slide underneath it. */ +.mm-editor-modal .mm-editor-side { + padding-top: 40px; +} +.mm-editor { + grid-area: canvas; + display: grid; + grid-template-columns: 1fr 320px; + background: var(--bg-app); + overflow: hidden; + height: 100%; +} +.mm-editor-main { + overflow-y: auto; + padding: 20px 28px 40px; +} +.mm-editor-side { + border-left: 1px solid var(--border-color); + background: var(--bg-toolbar); + overflow-y: auto; + padding: 14px 14px 24px; +} +.mm-editor-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 18px; + padding-bottom: 14px; + border-bottom: 1px solid var(--border-color); +} +.mm-name { + font-size: 18px; + font-weight: 600; + border: none; + background: transparent; + color: var(--text-primary); + padding: 2px 4px; + outline: none; +} +.mm-name:focus { + border-bottom: 1px solid var(--accent); +} +.mm-subhead { + font-size: 11px; + color: var(--text-muted); + padding: 2px 4px; +} +.mm-section { + margin-top: 22px; +} +.mm-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} +.mm-section-header h3 { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.mm-card-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.mm-card { + background: var(--bg-toolbar); + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; +} +.mm-card-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + cursor: pointer; + user-select: none; +} +.mm-card-header:hover { background: var(--hover-bg); } +.mm-chevron { transition: transform 0.15s; opacity: 0.6; } +.mm-type-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 4px; +} +.mm-type-label { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} +.mm-type-id { + font-size: 11px; + color: var(--text-muted); + font-family: ui-monospace, monospace; +} +.mm-builtin-tag { + font-size: 9px; + background: var(--border-color); + color: var(--text-muted); + padding: 1px 6px; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.mm-pair-count { + font-size: 10px; + color: var(--text-muted); +} +.mm-rel-arrow { + display: inline-block; + width: 24px; + text-align: center; + font-weight: 700; + color: var(--accent); +} +.mm-card-body { + padding: 12px 16px 16px; + border-top: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 12px; +} +.mm-row { + display: flex; + gap: 12px; + flex-wrap: wrap; +} +.mm-field { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 120px; +} +.mm-field > span { + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.mm-field-color { flex: 0 0 64px; } +.mm-field-color input[type="color"] { + width: 100%; + height: 28px; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 4px; + background: transparent; +} +.mm-input { + background: var(--input-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 5px 8px; + font-size: 12px; + color: var(--text-primary); + font-family: inherit; + outline: none; +} +.mm-input:focus { border-color: var(--accent); } +.mm-num { width: 64px; } +.mm-section-label { + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-top: 4px; + display: flex; + align-items: center; + gap: 8px; +} +.mm-hint { + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: var(--text-muted); + opacity: 0.8; +} +.mm-parent-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 4px 12px; +} +.mm-checkbox { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-primary); + cursor: pointer; +} +.mm-checkbox input { accent-color: var(--accent); } +.mm-prop-list, .mm-pair-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.mm-prop-row, .mm-pair-row { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.mm-prop-key { width: 100px; font-family: ui-monospace, monospace; } +.mm-prop-label { width: 140px; } +.mm-prop-type { width: 100px; } +.mm-prop-options { flex: 1; min-width: 120px; } +.mm-pair-arrow { + font-weight: 700; + color: var(--accent); + padding: 0 4px; +} +.mm-empty { + font-size: 11px; + color: var(--text-muted); + padding: 6px 0; + font-style: italic; +} +.mm-add-btn { + display: inline-flex; + align-items: center; + gap: 4px; + background: transparent; + border: 1px dashed var(--border-color); + border-radius: 4px; + padding: 5px 10px; + font-size: 11px; + color: var(--text-muted); + cursor: pointer; + align-self: flex-start; +} +.mm-add-btn:hover { background: var(--hover-bg); color: var(--text-primary); border-style: solid; } +.mm-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; height: 24px; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + color: var(--text-muted); + cursor: pointer; +} +.mm-icon-btn:hover { background: var(--hover-bg); color: var(--text-primary); border-color: var(--border-color); } +.mm-btn { + background: var(--input-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 6px 12px; + font-size: 12px; + color: var(--text-primary); + cursor: pointer; +} +.mm-btn:hover { border-color: var(--accent); } +.mm-footer-note { + margin-top: 32px; + padding: 12px 14px; + background: var(--hover-bg-faint); + border-left: 3px solid var(--accent); + font-size: 11px; + color: var(--text-muted); + border-radius: 0 4px 4px 0; +} +/* ─── Issues panel (metamodel side + LeftPanel accordion) ───────────── */ +.mm-issues { + display: flex; + flex-direction: column; + gap: 8px; +} +.mm-issues-header { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + color: var(--text-primary); + margin-bottom: 6px; +} +.mm-issues-count { + font-size: 10px; + background: var(--border-color); + color: var(--text-muted); + border-radius: 10px; + padding: 1px 8px; + min-width: 18px; + text-align: center; +} +.mm-issue { + padding: 8px 10px; + border-radius: 4px; + border-left: 3px solid var(--text-muted); + background: var(--hover-bg-faint); + display: flex; + flex-direction: column; + gap: 4px; +} +.mm-issue-error { border-left-color: var(--danger); } +.mm-issue-warning { border-left-color: #f59e0b; } +.mm-issue-msg { + font-size: 11px; + color: var(--text-primary); + line-height: 1.4; +} +.mm-link { + align-self: flex-start; + background: transparent; + border: none; + color: var(--accent); + font-size: 10px; + cursor: pointer; + padding: 0; +} +.mm-link:hover { text-decoration: underline; } +/* ─── Floating selection action bar ──────────────────────────────────── */ +.sel-bar { + position: fixed; + left: 50%; + bottom: 90px; + transform: translateX(-50%); + z-index: 60; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 6px 6px 12px; + border-radius: 999px; + background: color-mix(in srgb, var(--bg-panel) 92%, transparent); + border: 1px solid var(--border-color); + backdrop-filter: blur(14px) saturate(140%); + -webkit-backdrop-filter: blur(14px) saturate(140%); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.05) inset, + 0 12px 32px rgba(0, 0, 0, 0.32), + 0 2px 6px rgba(0, 0, 0, 0.16); + font-size: 12px; + color: var(--text-primary); + animation: sel-bar-in 180ms cubic-bezier(0.22, 1, 0.36, 1); +} +.sel-bar-count { + display: inline-flex; + align-items: center; + gap: 8px; + padding-right: 4px; +} +.sel-bar-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 7px; + border-radius: 999px; + background: var(--accent); + color: #fff; + font-weight: 600; + font-size: 11px; +} +.sel-bar-label { color: var(--text-muted); } +.sel-bar-divider { + width: 1px; + height: 22px; + background: var(--border-color); + margin: 0 2px; +} +.sel-bar-wrap { position: relative; display: inline-flex; } +.sel-bar-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + border: 1px solid transparent; + background: transparent; + color: var(--text-primary); + font-size: 12px; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; +} +.sel-bar-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--text-primary) 8%, transparent); + border-color: var(--border-color); +} +.sel-bar-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.sel-bar-btn-danger { color: var(--danger); } +.sel-bar-btn-danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger) 14%, transparent); + border-color: color-mix(in srgb, var(--danger) 40%, transparent); +} +.sel-bar-menu { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + min-width: 180px; + padding: 4px; + border-radius: 10px; + background: color-mix(in srgb, var(--bg-panel) 96%, transparent); + border: 1px solid var(--border-color); + backdrop-filter: blur(14px) saturate(140%); + -webkit-backdrop-filter: blur(14px) saturate(140%); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.32); + display: flex; + flex-direction: column; + gap: 1px; + animation: sel-bar-in 140ms cubic-bezier(0.22, 1, 0.36, 1); +} +.sel-bar-menu-item { + display: block; + width: 100%; + text-align: left; + padding: 8px 12px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--text-primary); + font-size: 12px; + cursor: pointer; + font-family: inherit; +} +.sel-bar-menu-item:hover { + background: color-mix(in srgb, var(--accent) 18%, transparent); +} +.sel-bar-menu-item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} +.sel-bar-menu-reason { + font-size: 10.5px; + color: var(--text-muted); + font-weight: 400; + line-height: 1.3; + max-width: 240px; + white-space: normal; +} +/* ─── Quick search palette (Cmd+P) ──────────────────────────────────── */ +.quick-search-bar { + position: fixed; + top: 6px; + left: 50%; + transform: translateX(-50%); + width: min(420px, 38vw); + z-index: 60; + -webkit-app-region: no-drag; + background: color-mix(in srgb, var(--bg-panel) 92%, transparent); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18); + backdrop-filter: blur(10px) saturate(140%); + -webkit-backdrop-filter: blur(10px) saturate(140%); +} +.quick-search-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + max-height: 60vh; + display: flex; + flex-direction: column; + background: color-mix(in srgb, var(--bg-panel) 96%, transparent); + border: 1px solid var(--border-color); + border-radius: 10px; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.05) inset, + 0 18px 48px rgba(0, 0, 0, 0.45), + 0 4px 10px rgba(0, 0, 0, 0.22); + backdrop-filter: blur(20px) saturate(140%); + -webkit-backdrop-filter: blur(20px) saturate(140%); + overflow: hidden; + animation: qs-pop-in 140ms cubic-bezier(0.22, 1, 0.36, 1); +} +.quick-search-bar .quick-search-inputrow { + padding: 5px 10px; + border-bottom: none; + gap: 8px; +} +.quick-search-bar .quick-search-inputrow input { + font-size: 12px; +} +.quick-search-inputrow { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid var(--border-color); + color: var(--text-primary); +} +.quick-search-inputrow input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text-primary); + font-size: 15px; + font-family: inherit; + min-width: 0; +} +.quick-search-inputrow input::placeholder { color: var(--text-muted); opacity: 0.7; } +.quick-search-kbd, .quick-search-hint kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 20px; + padding: 0 6px; + border-radius: 5px; + background: color-mix(in srgb, var(--text-primary) 8%, transparent); + border: 1px solid var(--border-color); + font-size: 11px; + font-family: inherit; + color: var(--text-muted); +} +.quick-search-results { + overflow-y: auto; + padding: 6px; +} +.quick-search-hint { + padding: 14px 14px 10px; + color: var(--text-muted); + font-size: 12px; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.quick-search-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border: none; + background: transparent; + color: var(--text-primary); + border-radius: 8px; + cursor: pointer; + text-align: left; + font: inherit; +} +.quick-search-kind { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 38px; + height: 18px; + padding: 0 6px; + border-radius: 4px; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.05em; + background: color-mix(in srgb, var(--text-primary) 10%, transparent); + color: var(--text-muted); +} +.quick-search-kind-node { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent); } +.quick-search-kind-edge { background: color-mix(in srgb, var(--text-primary) 14%, transparent); } +.quick-search-item-main { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} +.quick-search-item-label { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.quick-search-item-sub { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +/* ─── Quick search · per-result view chips & view kind ────────────── */ +.quick-search-kind-view { + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: color-mix(in srgb, var(--accent) 85%, var(--text-primary)); +} +.quick-search-views { + display: inline-flex; + gap: 4px; + flex-shrink: 0; + margin-left: 8px; +} +.quick-search-view-chip { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 6px; + border-radius: 4px; + font-size: 10.5px; + font-weight: 500; + background: color-mix(in srgb, var(--text-primary) 7%, transparent); + color: var(--text-muted); + border: 1px solid var(--border-color); + white-space: nowrap; + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; +} +.quick-search-view-chip { + cursor: pointer; + transition: background-color 0.12s, color 0.12s, border-color 0.12s; +} +.quick-search-view-chip:hover { + background: var(--accent); + color: white; + border-color: var(--accent); +} +/* ─── Quick search · AI agent additions ───────────────────────────── */ +.quick-search-bar { width: min(520px, 46vw); } +.quick-search-bar.ai-mode { + border-color: color-mix(in srgb, var(--accent) 55%, transparent); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent), + 0 4px 16px rgba(0, 0, 0, 0.22); +} +.qs-mode-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 5px; + border: 1px solid var(--border-color); + background: transparent; + color: var(--text-muted); + cursor: pointer; + flex-shrink: 0; + transition: background-color 0.12s, color 0.12s, border-color 0.12s; + font-size: 13px; + line-height: 1; +} +.qs-mode-toggle:hover { color: var(--text-primary); } +.qs-forge-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 5px; + border: 1px solid var(--border-color); + background: transparent; + color: var(--text-muted); + cursor: pointer; + flex-shrink: 0; + transition: background-color 0.12s, color 0.12s, border-color 0.12s; +} +.qs-forge-toggle:hover { + color: #9b5de5; + border-color: color-mix(in srgb, #9b5de5 50%, transparent); + background: color-mix(in srgb, #9b5de5 14%, transparent); +} +.qs-ai-banner { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border-bottom: 1px solid var(--border-color); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} +.qs-ai-banner-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; +} +.qs-ai-banner-row .qs-ai-spark { font-size: 13px; } +.qs-ai-banner-row .qs-ai-name { color: var(--text-primary); font-weight: 600; } +.qs-ai-banner-row .qs-ai-model { color: var(--text-muted); } +.qs-ai-banner-row .qs-ai-spacer { flex: 1; } +.qs-ai-mini-btn { + font-size: 10px; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid var(--border-color); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} +.qs-ai-mini-btn:hover { color: var(--text-primary); border-color: var(--text-muted); } +.qs-ai-status { font-size: 11px; color: var(--text-muted); display: inline-flex; align-items: center; gap: 6px; } +.qs-ai-status .qs-ai-dot { + display: inline-block; width: 8px; height: 8px; border-radius: 50%; + background: var(--accent); + animation: qs-ai-pulse 1.2s ease-in-out infinite; +} +.qs-ai-text { font-size: 12px; color: var(--text-primary); white-space: pre-wrap; line-height: 1.45; } +.qs-ai-meta { color: var(--text-muted); font-size: 10px; margin-top: 4px; display: inline-flex; gap: 8px; flex-wrap: wrap; } +.qs-ai-meta .qs-ai-meta-pill { + background: color-mix(in srgb, var(--text-primary) 8%, transparent); + border-radius: 4px; padding: 1px 6px; +} +.qs-ai-error { color: #ff7777; font-size: 11px; } +.qs-ai-examples { + display: flex; flex-direction: column; gap: 2px; + padding: 6px; +} +.qs-ai-example { + text-align: left; + padding: 6px 10px; + border-radius: 6px; + background: transparent; + border: 1px dashed var(--border-color); + color: var(--text-muted); + font-size: 12px; + cursor: pointer; + font-family: inherit; +} +.qs-ai-example:hover { + background: color-mix(in srgb, var(--accent) 12%, transparent); + border-style: solid; + color: var(--text-primary); +} +.qs-ai-example .qs-ai-example-icon { color: var(--accent); margin-right: 6px; } +.quick-search-kind-ai { + background: color-mix(in srgb, var(--accent) 30%, transparent); + color: var(--accent); +} +/* ─── AI Settings Modal ───────────────────────────────────────────── */ +.ai-settings-modal { + position: relative; + width: min(620px, 92vw); + max-width: 620px; + max-height: 88vh; + display: flex; + flex-direction: column; + overflow: hidden; /* footer stays put, body scrolls */ +} +.ai-settings-close { + position: absolute; + top: 8px; + right: 10px; + z-index: 2; + background: transparent; + border: none; + color: var(--text-secondary, var(--text-primary)); + font-size: 16px; + line-height: 1; + width: 28px; + height: 28px; + border-radius: 6px; + cursor: pointer; +} +.ai-settings-close:hover { + background: var(--bg-hover, rgba(255,255,255,0.08)); + color: var(--text-primary); +} +.ai-enable-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 10px 12px; + margin: 8px 0 10px; + background: var(--bg-toolbar, rgba(255,255,255,0.04)); + border: 1px solid var(--border-color); + border-radius: 8px; +} +.ai-enable-toggle { + display: inline-flex; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; +} +.ai-enable-toggle input { position: absolute; opacity: 0; width: 0; height: 0; } +.ai-enable-switch { + position: relative; + width: 36px; + height: 20px; + border-radius: 999px; + background: var(--border-color); + transition: background 0.15s ease; + flex: 0 0 auto; +} +.ai-enable-switch::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + transition: transform 0.15s ease; + box-shadow: 0 1px 2px rgba(0,0,0,0.3); +} +.ai-enable-toggle input:checked + .ai-enable-switch { + background: rgb(var(--accent-rgb)); +} +.ai-enable-toggle input:checked + .ai-enable-switch::after { + transform: translateX(16px); +} +.ai-enable-toggle input:focus-visible + .ai-enable-switch { + outline: 2px solid rgb(var(--accent-rgb)); + outline-offset: 2px; +} +.ai-enable-label { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} +.ai-enable-hint { + font-size: 12px; + color: var(--text-secondary, var(--text-primary)); + flex: 1 1 200px; +} +.ai-settings-modal .ai-settings-section { + flex: 1 1 auto; + overflow-y: auto; + min-height: 0; + padding-right: 4px; /* room for scrollbar so content doesn't shift */ +} +.ai-settings-tabs { + display: flex; + gap: 0; + margin: 6px 0 14px; + border-bottom: 1px solid var(--border-color); +} +.ai-settings-tab { + padding: 8px 14px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-muted); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 500; + transition: color 0.12s, border-color 0.12s; + display: inline-flex; + align-items: center; + gap: 6px; + margin-bottom: -1px; +} +.ai-settings-tab:hover { color: var(--text-primary); } +.ai-settings-tab .ai-settings-tab-active-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; +} +.ai-settings-section { display: flex; flex-direction: column; gap: 12px; font-size: 12px; } +.ai-settings-row { display: flex; flex-direction: column; gap: 4px; } +.ai-settings-row > label { color: var(--text-muted); font-size: 11px; } +.ai-settings-row .ai-settings-input-wrap { + display: flex; align-items: center; gap: 4px; + background: var(--bg-canvas); + border: 1px solid var(--border-color); + border-radius: 5px; + padding: 0 4px 0 0; +} +.ai-settings-row input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text-primary); + font: inherit; + font-size: 12px; + padding: 6px 8px; + min-width: 0; +} +.ai-settings-row .ai-settings-eye { + background: transparent; border: none; color: var(--text-muted); + cursor: pointer; padding: 4px 6px; font-size: 11px; +} +.ai-settings-row .ai-settings-eye:hover { color: var(--text-primary); } +.ai-settings-help { + font-size: 11px; + color: var(--text-muted); + line-height: 1.5; +} +.ai-settings-help code { + background: color-mix(in srgb, var(--text-primary) 8%, transparent); + padding: 1px 5px; + border-radius: 3px; + font-size: 11px; +} +.ai-settings-help pre { + margin: 4px 0 0; + padding: 6px 8px; + font-size: 11px; + background: var(--bg-canvas); + border: 1px solid var(--border-color); + border-radius: 4px; + overflow-x: auto; + color: var(--text-primary); +} +.ai-settings-actions { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + margin-top: 4px; +} +.ai-settings-actions .ai-test-result { + font-size: 11px; + padding: 3px 8px; + border-radius: 4px; +} +.ai-active-badge { + display: inline-flex; align-items: center; gap: 4px; + padding: 3px 8px; + border-radius: 4px; + background: color-mix(in srgb, var(--accent) 22%, transparent); + color: var(--accent); + font-size: 11px; + font-weight: 600; +} +.ai-set-default-btn { + background: var(--accent); + color: white; + border: 1px solid var(--accent); + padding: 4px 10px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + cursor: pointer; + font-family: inherit; +} +.ai-set-default-btn:hover { filter: brightness(1.1); } +.ai-settings-footer { + margin-top: 16px; + display: flex; justify-content: space-between; align-items: center; + border-top: 1px solid var(--border-color); + padding-top: 12px; +} +.ai-settings-saved { + font-size: 10px; + color: var(--text-muted); + font-style: italic; +} +/* ─── Welcome / Splash screen ─────────────────────────────────────────────── */ +.welcome-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + background: rgba(220, 224, 240, 0.65); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + animation: welcome-fadein 0.2s ease; +} +.welcome-card { + display: grid; + grid-template-columns: 1fr 1fr; + min-width: 640px; + max-width: 760px; + width: 100%; + border-radius: 20px; + overflow: hidden; + box-shadow: 0 8px 40px rgba(30, 40, 100, 0.18), 0 0 0 1px rgba(0,0,0,0.07); + animation: welcome-slidein 0.22s cubic-bezier(0.22,1,0.36,1); +} +/* ── Left column ── */ +.welcome-left { + background: #ffffff; + padding: 44px 40px 40px; + display: flex; + flex-direction: column; + gap: 0; +} +.welcome-wordmark { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 36px; +} +.welcome-wordmark-icon { + flex-shrink: 0; +} +.welcome-wordmark-text { + font-size: 16px; + font-weight: 600; + color: #1a1a2e; + letter-spacing: -0.3px; + font-style: normal; +} +.welcome-wordmark-text em { + font-style: normal; + color: #3b6fe6; +} +.welcome-heading { + font-size: 28px; + font-weight: 700; + line-height: 1.2; + color: #111827; + letter-spacing: -0.8px; + margin: 0 0 12px; +} +.welcome-lead { + font-size: 13px; + line-height: 1.6; + color: #6b7280; + margin: 0 0 32px; +} +.welcome-cta-group { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: auto; +} +.welcome-mm-picker { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + background: rgba(127, 127, 127, 0.06); + border: 1px solid rgba(127, 127, 127, 0.18); + border-radius: 10px; + margin-top: 4px; +} +.welcome-mm-picker-title { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); + padding: 2px 4px 6px; +} +.welcome-mm-option { + text-align: left; + background: transparent; + border: 1px solid transparent; + border-radius: 8px; + padding: 10px 12px; + cursor: pointer; + transition: background 0.14s, border-color 0.14s; +} +.welcome-mm-option:hover { + background: rgba(59, 111, 230, 0.08); + border-color: rgba(59, 111, 230, 0.3); +} +.welcome-mm-option-name { + font-size: 13px; + font-weight: 600; + color: var(--text); + margin-bottom: 2px; + display: flex; + align-items: center; + justify-content: space-between; +} +.welcome-mm-option-check { + font-size: 12px; + color: var(--accent); +} +.welcome-mm-option-desc { + font-size: 11px; + color: var(--text-muted); + line-height: 1.4; +} +/* "New model" + dropdown arrow split */ +.welcome-btn-group { + display: flex; + flex-direction: row; + align-items: stretch; +} +.welcome-btn-group .welcome-btn { + flex: 1; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.welcome-btn-mm { + padding: 0 12px !important; + min-width: unset !important; + flex-shrink: 0; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + border-left: 1px solid rgba(127,127,127,.2) !important; + margin-left: -1px; +} +.welcome-sample-link { + margin-top: 14px; + text-align: center; +} +.welcome-link { + background: none; + border: none; + padding: 4px 6px; + font-size: 12px; + color: var(--text-muted); + cursor: pointer; + text-decoration: underline; + text-decoration-color: rgba(127, 127, 127, 0.35); + text-underline-offset: 3px; + transition: color 0.14s, text-decoration-color 0.14s; +} +.welcome-link:hover { + color: var(--text); + text-decoration-color: currentColor; +} +.welcome-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 11px 20px; + border-radius: 10px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.14s, opacity 0.14s, border-color 0.14s; + border: 1px solid transparent; + width: 100%; +} +.welcome-btn-primary { + background: #3b6fe6; + color: #ffffff; + border-color: #3b6fe6; +} +.welcome-btn-primary:hover { + background: #2d5fd4; + border-color: #2d5fd4; +} +.welcome-btn-ghost { + background: #f3f4f6; + color: #374151; + border-color: #e5e7eb; +} +.welcome-btn-ghost:hover { + background: #e9eaf0; +} +.welcome-btn-forge { + background: linear-gradient(135deg, #f0f4ff, #faf0ff); + color: #5b3fb0; + border-color: #dcc9f7; +} +.welcome-btn-forge:hover { + background: linear-gradient(135deg, #e4eaff, #f3e4ff); +} +.welcome-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} +/* ── Right column ── */ +.welcome-right { + background: #f8f9fc; + padding: 40px 32px 40px; + display: flex; + flex-direction: column; + border-left: 1px solid #e8eaf0; +} +.welcome-right-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #9ca3af; + margin: 0 0 10px 4px; +} +.welcome-recent { + display: flex; + flex-direction: column; + gap: 2px; +} +.welcome-recent-item { + display: flex; + align-items: center; + gap: 9px; + background: transparent; + border: none; + border-radius: 8px; + padding: 8px 10px; + cursor: pointer; + color: #1f2937; + transition: background 0.13s; + text-align: left; + width: 100%; +} +.welcome-recent-item:hover { + background: rgba(59, 111, 230, 0.07); +} +.welcome-recent-icon { + flex-shrink: 0; + color: #3b6fe6; + opacity: 0.75; + display: flex; +} +.welcome-recent-name { + flex: 1; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #111827; +} +.welcome-recent-date { + font-size: 11px; + color: #9ca3af; + flex-shrink: 0; +} +.welcome-sample-card { + flex: 1; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 16px; + background: #ffffff; + border: 1px solid #e3e6ef; + border-radius: 14px; + cursor: pointer; + text-align: left; + font: inherit; + transition: border-color 0.14s, box-shadow 0.14s, transform 0.14s; +} +.welcome-sample-card:hover { + border-color: #b9c9f5; + box-shadow: 0 6px 20px rgba(59, 111, 230, 0.12); + transform: translateY(-1px); +} +.welcome-sample-card:focus-visible { + outline: 2px solid #3b6fe6; + outline-offset: 2px; +} +.welcome-sample-preview { + display: block; + margin-bottom: 6px; + padding: 10px; + background: #f5f7fb; + border-radius: 8px; +} +.welcome-sample-title { + font-size: 15px; + font-weight: 650; + color: #111827; + letter-spacing: -0.2px; +} +.welcome-sample-desc { + font-size: 12px; + line-height: 1.5; + color: #6b7280; +} +.welcome-sample-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.welcome-sample-tag { + font-size: 10.5px; + padding: 2px 7px; + border-radius: 999px; + background: #eef2fd; + color: #3b4eab; +} +.welcome-sample-stats { + font-size: 11px; + color: #9ca3af; +} +.welcome-sample-cta { + margin-top: auto; +} +.welcome-sample-card:hover .welcome-sample-cta { + background: #2d5fd4; + border-color: #2d5fd4; +} +/* ─── Treemap view ───────────────────────────────────────────────────── */ +.treemap-wrap-outer { + grid-area: canvas; + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + background: var(--bg-canvas); +} +.treemap-wrap { + position: relative; + flex: 1 1 auto; + min-height: 0; + background: var(--bg-canvas); + overflow: hidden; +} +.treemap-breadcrumb { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border-color); + font-size: 12px; + flex-wrap: wrap; + min-height: 32px; + box-sizing: border-box; +} +.tm-bc-item { + background: transparent; + border: 1px solid transparent; + color: var(--text-secondary); + padding: 3px 8px; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + transition: background 0.12s, color 0.12s; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tm-bc-item:not(:disabled):hover { + background: var(--bg-hover, rgba(255,255,255,0.06)); + color: var(--text-primary); +} +.tm-bc-sep { + color: var(--text-muted); + font-size: 13px; + user-select: none; +} +.tm-bc-back { + margin-left: auto; + background: var(--bg-hover, rgba(255,255,255,0.06)); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 3px 10px; + border-radius: 4px; + font-size: 11px; + cursor: pointer; + transition: background 0.12s; +} +.tm-bc-back:hover { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +.tm-bc-spacer { flex: 1 1 auto; } +.tm-bc-sizeby { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-muted); +} +.tm-bc-sizeby select { + background: var(--bg-hover, rgba(255,255,255,0.06)); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 2px 6px; + font-size: 11px; + cursor: pointer; +} +.tm-tt-dim { opacity: 0.65; } +.treemap-empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: var(--text-muted); +} +/* Drill-down zoom animations */ +.treemap-tooltip { + position: fixed; + z-index: 9999; + background: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 8px 12px; + pointer-events: none; + display: flex; + flex-direction: column; + gap: 3px; + box-shadow: var(--shadow-md); + max-width: 240px; +} +.tm-tt-type { font-size: 10px; font-weight: 600; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.6px; } +.tm-tt-label { font-size: 13px; font-weight: 600; color: var(--text-primary); } +.tm-tt-meta { font-size: 11px; color: var(--text-secondary); } +.tm-tt-hint { font-size: 10px; color: var(--text-muted); margin-top: 2px; } +/* ── Matrix view (DSM) ─────────────────────────────────────────────────────── */ +.mx-wrap { + grid-area: canvas; + display: flex; + flex-direction: column; + min-height: 0; + background: var(--bg-canvas); + overflow: hidden; + position: relative; +} +.mx-toolbar { + display: flex; + align-items: baseline; + gap: 12px; + padding: 8px 14px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} +.mx-title { + font-size: 13px; + font-weight: 700; + color: var(--text); +} +.mx-hint { + font-size: 11px; + color: var(--text-muted); +} +.mx-scroll { + flex: 1; + min-height: 0; + overflow: auto; + padding: 0; +} +.mx-empty { + margin: auto; + text-align: center; + padding: 40px; + max-width: 420px; +} +.mx-empty-title { + font-size: 15px; + font-weight: 600; + color: var(--text); + margin-bottom: 6px; +} +.mx-empty-sub { + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; +} +.mx-table { + border-collapse: separate; + border-spacing: 0; + font-size: 12px; +} +/* Column headers: vertical labels for compactness */ +.mx-table thead th.mx-colhead { + position: sticky; + top: 0; + z-index: 3; + height: 140px; + width: 34px; + min-width: 34px; + max-width: 34px; + padding: 6px 2px; + background: var(--bg-panel); + border-left: 1px solid var(--border-color); + border-bottom: 2px solid var(--border-color); + cursor: pointer; + vertical-align: bottom; + overflow: hidden; +} +.mx-colhead-inner { + display: flex; + align-items: center; + gap: 4px; + writing-mode: vertical-rl; + transform: rotate(180deg); + height: 100%; + width: 100%; + padding: 4px 0; + white-space: nowrap; +} +.mx-colhead-text { + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; + color: var(--text); + font-weight: 500; + font-size: 11px; +} +.mx-table thead th.mx-colhead:hover .mx-colhead-text, +.mx-table th.mx-rowhead:hover .mx-rowhead-text { + color: var(--accent); +} +/* Corner cell */ +.mx-corner { + position: sticky; + top: 0; + left: 0; + z-index: 4; + background: var(--bg-panel); + border-bottom: 2px solid var(--border-color); + border-right: 2px solid var(--border-color); + vertical-align: bottom; + padding: 6px 8px; +} +.mx-corner-from { + font-size: 10px; + color: var(--text-muted); + font-style: italic; + white-space: nowrap; +} +/* Row headers */ +.mx-table th.mx-rowhead { + position: sticky; + left: 0; + z-index: 2; + background: var(--bg-panel); + border-right: 2px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + padding: 0 10px; + height: 34px; + text-align: left; + white-space: nowrap; + cursor: pointer; + font-weight: 500; +} +.mx-rowhead .mx-swatch, +.mx-colhead .mx-swatch { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 2px; + margin-right: 6px; + flex-shrink: 0; + vertical-align: middle; +} +.mx-rowhead-text { + display: inline-block; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + color: var(--text); +} +/* Cells */ +.mx-cell { + width: 34px; + min-width: 34px; + max-width: 34px; + height: 34px; + padding: 0; + text-align: center; + vertical-align: middle; + border-left: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + cursor: pointer; + background: var(--bg-canvas); +} +.mx-cell.mx-axis-hl { + background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); +} +.mx-cell:not(.mx-diag):hover { + background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); + box-shadow: inset 0 0 0 1px var(--accent, #3b82f6); +} +.mx-diag { + cursor: default; + background: repeating-linear-gradient( + 45deg, + color-mix(in srgb, var(--text-muted, #888) 14%, transparent), + color-mix(in srgb, var(--text-muted, #888) 14%, transparent) 3px, + transparent 3px, + transparent 6px + ); +} +.mx-diag-mark { display: none; } +.mx-dot { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 50%; + vertical-align: middle; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25); +} +.mx-dot-count { + position: absolute; + inset: -5px -7px auto auto; + min-width: 14px; + height: 14px; + padding: 0 3px; + border-radius: 7px; + background: var(--bg-panel, #1e1e2e); + color: var(--text-main, #cdd6f4); + font-size: 9px; + font-weight: 700; + line-height: 14px; + text-align: center; + pointer-events: none; + box-shadow: 0 0 0 1px var(--border-color, #45475a); +} +.mx-cell.mx-sel { + box-shadow: inset 0 0 0 2px var(--accent, #3b82f6); + background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); +} +.mx-cell.mx-readonly { + cursor: default; +} +.mx-cell.mx-readonly:not(.mx-diag):hover { + background: var(--bg-canvas); + box-shadow: none; +} +/* ── Multi-relation popup ─────────────────────────────────────────────── */ +.mx-popup { + position: fixed; + z-index: 9000; + transform: translateX(-50%); + min-width: 220px; + max-width: 340px; + background: var(--bg-panel, #1e1e2e); + border: 1px solid var(--border-color, #45475a); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + overflow: hidden; +} +.mx-popup-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px 6px; + border-bottom: 1px solid var(--border-color, #45475a); + font-size: 11px; + color: var(--text-muted, #888); + white-space: nowrap; + overflow: hidden; +} +.mx-popup-from, +.mx-popup-to { + font-weight: 600; + color: var(--text-main, #cdd6f4); + overflow: hidden; + text-overflow: ellipsis; + max-width: 110px; +} +.mx-popup-arrow { + flex-shrink: 0; + opacity: 0.5; +} +.mx-popup-list { + list-style: none; + margin: 0; + padding: 4px 0; +} +.mx-popup-item { + display: flex; + align-items: center; + padding: 0 4px 0 12px; + gap: 4px; +} +.mx-popup-item:hover { + background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); +} +.mx-popup-item-sel { + background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); +} +.mx-popup-select { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 4px; + background: none; + border: none; + cursor: pointer; + color: var(--text-main, #cdd6f4); + font-size: 12px; + text-align: left; + min-width: 0; +} +.mx-popup-dot { + flex-shrink: 0; + width: 10px; + height: 10px; + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3); +} +.mx-popup-rel-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mx-popup-rel-type { + color: var(--text-muted, #888); + font-size: 11px; +} +.mx-popup-remove { + flex-shrink: 0; + width: 22px; + height: 22px; + border: none; + background: none; + cursor: pointer; + color: var(--text-muted, #888); + font-size: 16px; + line-height: 1; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; +} +.mx-popup-remove:hover { + background: color-mix(in srgb, var(--danger, #f38ba8) 20%, transparent); + color: var(--danger, #f38ba8); +} +@keyframes forge-panel-in { + from { transform: translate(-50%, -10px) scale(0.98); opacity: 0; } + to { transform: translate(-50%, 0) scale(1); opacity: 1; } +} +@keyframes forge-progress-pulse { + 0%, 100% { opacity: 0.35; transform: scale(0.85); } + 50% { opacity: 1; transform: scale(1.15); } +} +@keyframes forge-progress-in { + from { opacity: 0; transform: translateY(-3px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes sel-bar-in { + from { transform: translate(-50%, 6px); opacity: 0; } + to { transform: translate(-50%, 0); opacity: 1; } +} +@keyframes qs-pop-in { + from { transform: translateY(-4px) scale(0.99); opacity: 0 } + to { transform: translateY(0) scale(1); opacity: 1 } +} +@keyframes qs-ai-pulse { + 0%, 100% { opacity: 0.4; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.2); } +} +@keyframes welcome-fadein { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes welcome-slidein { + from { opacity: 0; transform: translateY(12px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} diff --git a/tests/aiProviders.test.ts b/apps/studio/tests/aiProviders.test.ts similarity index 100% rename from tests/aiProviders.test.ts rename to apps/studio/tests/aiProviders.test.ts diff --git a/tests/aiRunner.test.ts b/apps/studio/tests/aiRunner.test.ts similarity index 99% rename from tests/aiRunner.test.ts rename to apps/studio/tests/aiRunner.test.ts index 782f8b82..b22a5d8f 100644 --- a/tests/aiRunner.test.ts +++ b/apps/studio/tests/aiRunner.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { runAIPrompt } from '../src/renderer/src/ai/runner' import { defaultAISettings } from '../src/renderer/src/ai/settings' -import type { DiagramFacade } from '../src/renderer/src/ai/diagramFacade' -import type { C4Node, C4Relation } from '../src/renderer/src/types/c4' +import type { DiagramFacade } from '@radical/common/ai/diagramFacade' +import type { C4Node, C4Relation } from '@radical/common/c4' function makeFacade(): DiagramFacade & { _nodes: Record; _rels: Record } { const nodes: Record = {} diff --git a/tests/aiSettings.test.ts b/apps/studio/tests/aiSettings.test.ts similarity index 100% rename from tests/aiSettings.test.ts rename to apps/studio/tests/aiSettings.test.ts diff --git a/tests/aiSystemPrompt.test.ts b/apps/studio/tests/aiSystemPrompt.test.ts similarity index 97% rename from tests/aiSystemPrompt.test.ts rename to apps/studio/tests/aiSystemPrompt.test.ts index f68173db..b0111ea9 100644 --- a/tests/aiSystemPrompt.test.ts +++ b/apps/studio/tests/aiSystemPrompt.test.ts @@ -5,8 +5,8 @@ import { buildMetamodelMessage, buildSystemMessages, } from '../src/renderer/src/ai/systemPrompt' -import type { C4Node, C4Relation } from '../src/renderer/src/types/c4' -import type { Metamodel } from '../src/renderer/src/types/metamodel' +import type { C4Node, C4Relation } from '@radical/common/c4' +import type { Metamodel } from '@radical/common/metamodel' const node = (over: Partial): C4Node => ({ id: 'n', type: 'system', label: 'L', collapsed: false, diff --git a/tests/cola-oscillation-test.mjs b/apps/studio/tests/cola-oscillation-test.mjs similarity index 100% rename from tests/cola-oscillation-test.mjs rename to apps/studio/tests/cola-oscillation-test.mjs diff --git a/tests/d3adaptor-test.mjs b/apps/studio/tests/d3adaptor-test.mjs similarity index 100% rename from tests/d3adaptor-test.mjs rename to apps/studio/tests/d3adaptor-test.mjs diff --git a/tests/fintechSample.test.ts b/apps/studio/tests/fintechSample.test.ts similarity index 96% rename from tests/fintechSample.test.ts rename to apps/studio/tests/fintechSample.test.ts index b2cb0c9f..4937a804 100644 --- a/tests/fintechSample.test.ts +++ b/apps/studio/tests/fintechSample.test.ts @@ -10,8 +10,8 @@ */ import { describe, it, expect } from 'vitest' import sample from '../src/renderer/src/store/fintechSampleData.json' -import { builtInGovernanceMetamodel, isParentAllowed } from '../src/renderer/src/types/metamodel' -import { sanitizeWireframeSvg } from '../src/renderer/src/ai/mockupWireframe' +import { builtInGovernanceMetamodel, isParentAllowed } from '@radical/common/metamodel' +import { sanitizeWireframeSvg } from '@radical/common/wireframe' type Node = { id: string; type: string; parentId?: string; [k: string]: unknown } type Relation = { id: string; sourceId: string; targetId: string; relationType?: string } diff --git a/tests/forgeClarify.test.ts b/apps/studio/tests/forgeClarify.test.ts similarity index 100% rename from tests/forgeClarify.test.ts rename to apps/studio/tests/forgeClarify.test.ts diff --git a/tests/forgePrompts.test.ts b/apps/studio/tests/forgePrompts.test.ts similarity index 100% rename from tests/forgePrompts.test.ts rename to apps/studio/tests/forgePrompts.test.ts diff --git a/apps/studio/tests/hostDetection.test.ts b/apps/studio/tests/hostDetection.test.ts new file mode 100644 index 00000000..54dfba93 --- /dev/null +++ b/apps/studio/tests/hostDetection.test.ts @@ -0,0 +1,54 @@ +/** + * host() picks the Electron preload API, the VS Code webview bridge or the + * plain browser host. The VS Code case runs last: that host is cached for + * the module's lifetime because acquireVsCodeApi() may only be called once. + */ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { host } from '../src/renderer/src/platform/host' + +const g = globalThis as Record + +afterEach(() => { + delete g.electronAPI +}) + +describe('host()', () => { + it('is the capability-less web host in a plain browser', () => { + expect(host().kind).toBe('web') + expect(host().readFile).toBeUndefined() + }) + + it('wraps the Electron preload API, read on every call', async () => { + const readFile = vi.fn(async () => ({ success: true, content: '{}' })) + g.electronAPI = { readFile } + const h = host() + expect(h.kind).toBe('electron') + expect(await h.readFile!('/m.radical')).toEqual({ success: true, content: '{}' }) + expect(readFile).toHaveBeenCalledWith('/m.radical') + delete g.electronAPI + expect(host().kind).toBe('web') + }) + + it('builds the VS Code webview host once, from the injected boot data', async () => { + const postMessage = vi.fn() + const acquireVsCodeApi = vi.fn(() => ({ postMessage })) + g.acquireVsCodeApi = acquireVsCodeApi + g.__RADICAL_VSCODE__ = { filePath: '/work/model.radical' } + const addEventListener = vi.fn() // the webview's window; node has none + g.addEventListener = addEventListener + try { + const h = host() + expect(h.kind).toBe('vscode') + expect(addEventListener).toHaveBeenCalledWith('message', expect.any(Function)) + expect(host()).toBe(h) + expect(acquireVsCodeApi).toHaveBeenCalledTimes(1) + expect(await h.getWatchedPath!()).toBe('/work/model.radical') + await h.writeFile!('/work/model.radical', '{}') + expect(postMessage).toHaveBeenCalledWith({ type: 'writeFile', filePath: '/work/model.radical', content: '{}' }) + } finally { + delete g.acquireVsCodeApi + delete g.__RADICAL_VSCODE__ + delete g.addEventListener + } + }) +}) diff --git a/tests/lazyNodeHydration.test.ts b/apps/studio/tests/lazyNodeHydration.test.ts similarity index 96% rename from tests/lazyNodeHydration.test.ts rename to apps/studio/tests/lazyNodeHydration.test.ts index dc251605..afe16b9c 100644 --- a/tests/lazyNodeHydration.test.ts +++ b/apps/studio/tests/lazyNodeHydration.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { documents } from '../src/renderer/src/store/documentStore' -import { useDiagramStore } from '../src/renderer/src/store/diagramStore' -import type { DiagramData } from '../src/renderer/src/types/c4' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import type { DiagramData } from '@radical/common/c4' class MemLS { private map = new Map() diff --git a/tests/matchConcepts.test.ts b/apps/studio/tests/matchConcepts.test.ts similarity index 89% rename from tests/matchConcepts.test.ts rename to apps/studio/tests/matchConcepts.test.ts index e77609d2..ed5f157a 100644 --- a/tests/matchConcepts.test.ts +++ b/apps/studio/tests/matchConcepts.test.ts @@ -5,13 +5,11 @@ * surfacing/steering by relevant prior art. */ import { describe, it, expect } from 'vitest' -import { resolve } from 'node:path' -import { readCatalogue, buildIndex } from '../tools/hubCatalogue' +import { readCatalogue, buildIndex } from '@radical/hub-catalogue' import { findRelevantConcepts, scoreConceptRelevance } from '../src/renderer/src/hub/matchConcepts' -import type { HubConceptSummary } from '../src/renderer/src/hub/hubFormat' +import type { HubConceptSummary } from '@radical/common/hubFormat' -const HUB_DIR = resolve(__dirname, '../hub') -const concepts: HubConceptSummary[] = buildIndex(readCatalogue(HUB_DIR)) +const concepts: HubConceptSummary[] = buildIndex(readCatalogue()) describe('findRelevantConcepts', () => { it('ranks the Anti-Corruption Layer pattern top for a legacy-integration description', () => { diff --git a/tests/mdFolderDocument.test.ts b/apps/studio/tests/mdFolderDocument.test.ts similarity index 98% rename from tests/mdFolderDocument.test.ts rename to apps/studio/tests/mdFolderDocument.test.ts index 37687d68..0c09cbf0 100644 --- a/tests/mdFolderDocument.test.ts +++ b/apps/studio/tests/mdFolderDocument.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { documents } from '../src/renderer/src/store/documentStore' -import type { DiagramData } from '../src/renderer/src/types/c4' +import type { DiagramData } from '@radical/common/c4' class MemLS { private map = new Map() diff --git a/tests/mockupWireframe.test.ts b/apps/studio/tests/mockupWireframe.test.ts similarity index 93% rename from tests/mockupWireframe.test.ts rename to apps/studio/tests/mockupWireframe.test.ts index c946143d..b2b477bf 100644 --- a/tests/mockupWireframe.test.ts +++ b/apps/studio/tests/mockupWireframe.test.ts @@ -4,14 +4,11 @@ * wireframe surviving an md-folder round-trip as multi-line frontmatter. */ import { describe, it, expect } from 'vitest' -import { - buildWireframePrompt, - sanitizeWireframeSvg, - MAX_WIREFRAME_CHARS, -} from '../src/renderer/src/ai/mockupWireframe' -import { builtInGovernanceMetamodel, inferRelationType, isRelationAllowed } from '../src/renderer/src/types/metamodel' -import { serializeToMdFolder, deserializeFromMdFolder } from '../src/renderer/src/persist/mdFolder' -import type { C4Node, C4Relation } from '../src/renderer/src/types/c4' +import { buildWireframePrompt } from '../src/renderer/src/ai/mockupWireframe' +import { sanitizeWireframeSvg, MAX_WIREFRAME_CHARS } from '@radical/common/wireframe' +import { builtInGovernanceMetamodel, inferRelationType, isRelationAllowed } from '@radical/common/metamodel' +import { serializeToMdFolder, deserializeFromMdFolder } from '@radical/common/formats/mdFolder' +import type { C4Node, C4Relation } from '@radical/common/c4' function node(partial: Partial & Pick & Record): C4Node { return { collapsed: false, x: 0, y: 0, width: 220, height: 190, ...partial } as C4Node diff --git a/apps/studio/tests/modelFacadeParity.test.ts b/apps/studio/tests/modelFacadeParity.test.ts new file mode 100644 index 00000000..e98c0b8f --- /dev/null +++ b/apps/studio/tests/modelFacadeParity.test.ts @@ -0,0 +1,116 @@ +/** + * The headless facade (@radical/common/ai/modelFacade, used outside Studio) + * must edit a model exactly like Studio's store does. Both start from the same + * document, run the same AI tool calls, including ones the metamodel refuses, + * and must end with the same tool results and the same model. + * + * Geometry is left out of the comparison: only Studio resizes a parent around + * a new child, and it keeps per-view positions. + */ +import { describe, it, expect } from 'vitest' +import { readCatalogue } from '@radical/hub-catalogue' +import { createModelFacade } from '@radical/common/ai/modelFacade' +import { buildToolHandlers } from '@radical/common/ai/tools' +import type { DiagramFacade } from '@radical/common/ai/diagramFacade' +import type { ToolRunContext, ToolResult } from '@radical/common/ai/tools' +import type { DiagramData } from '@radical/common/c4' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import { createStoreFacade } from '../src/renderer/src/ai/useDiagramFacade' + +type Call = [tool: string, input: Record] + +function run(diagram: DiagramFacade, calls: Call[]) { + const tempToReal = new Map() + let placed = 0 + const ctx: ToolRunContext = { + diagram, + resolveId: (id) => tempToReal.get(id) ?? id, + registerTempId: (t, r) => { tempToReal.set(t, r) }, + resetTempIds: () => { tempToReal.clear() }, + placeNext: () => { placed++; return { x: placed * 300, y: 0 } }, + } + const handlers = buildToolHandlers() + const results: ToolResult[] = calls.map(([tool, input]) => handlers.get(tool)!(input, ctx)) + return { results, tempToReal } +} + +/** Replaces generated ids with the tempId they were created under, and drops + * geometry, so two runs with different id generators can be compared. */ +function normalize(data: DiagramData, tempToReal: Map) { + const alias = new Map([...tempToReal].map(([temp, real]) => [real, `@${temp}`])) + const id = (x: unknown) => (typeof x === 'string' ? alias.get(x) ?? x : x) + const text = (s: string) => s.replace(/[0-9a-f]{8}-[0-9a-f-]{27}|id\d+/g, (m) => alias.get(m) ?? m) + const nodes = data.nodes + .map(({ x: _x, y: _y, width: _w, height: _h, ...n }) => ({ ...n, id: id(n.id), parentId: id(n.parentId) })) + .sort((a, b) => String(a.id).localeCompare(String(b.id))) + const relations = data.relations + .map(({ id: _id, ...r }) => ({ ...r, sourceId: id(r.sourceId), targetId: id(r.targetId) })) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) + const views = (data.views ?? []) + .map(({ positions: _p, viewport: _v, ...v }) => ({ ...v, id: id(v.id), nodeIds: v.nodeIds.map(id) })) + .sort((a, b) => String(a.id).localeCompare(String(b.id))) + return { nodes, relations, views, text } +} + +const blueprint = readCatalogue().find((e) => e.doc.hub.category === 'blueprint')!.doc +const { hub: _hub, ...blueprintDoc } = blueprint + +describe('headless facade matches the Studio store', () => { + it('produces the same tool results and the same model', () => { + const store = useDiagramStore.getState() + store.loadDiagram(blueprintDoc as unknown as DiagramData) + const start = useDiagramStore.getState().saveDiagram() + const someNode = start.nodes.find((n) => !n.parentId)! + const someRelation = start.relations[0] + + let seq = 0 + const headless = createModelFacade(start, { newId: () => `id${++seq}` }) + + const calls: Call[] = [ + ['add_node', { tempId: 'sys', type: 'system', label: 'Billing', description: 'Invoices' }], + ['add_node', { tempId: 'api', type: 'container', label: 'Billing API', parentId: 'sys', technology: 'Go' }], + ['add_node', { tempId: 'orphan', type: 'component', label: 'Orphan' }], // refused: containment + ['add_node', { tempId: 'req', type: 'requirement', label: 'Invoices are immutable' }], + ['add_relation', { sourceId: 'api', targetId: someNode.id, label: 'reads' }], + ['add_relation', { sourceId: 'req', targetId: 'sys' }], + ['update_node', { id: 'sys', label: 'Billing Platform', properties: {} }], + ['update_node', { id: 'api', type: 'system' }], // retype + ['update_relation', { id: someRelation.id, label: 'renamed' }], + ['create_view', { tempId: 'v', name: 'Billing', nodeIds: ['sys', 'api', 'sys', 'ghost'], kind: 'table', active: true }], + ['add_node', { tempId: 'db', type: 'container', label: 'Ledger', parentId: 'sys' }], // lands in the active view + ['set_view_nodes', { id: 'v', nodeIds: ['db', 'sys'] }], + ['delete_relation', { id: someRelation.id }], + ['delete_node', { id: 'sys' }], // cascades + ['set_active_view', { id: null }], + ['delete_view', { id: 'v' }], + ] + + const a = run(createStoreFacade(), calls) + const b = run(headless, calls) + + const na = normalize(useDiagramStore.getState().saveDiagram(), a.tempToReal) + const nb = normalize(headless.toDiagramData(), b.tempToReal) + + expect(b.results.map((r) => [r.ok, nb.text(r.resultText)])) + .toEqual(a.results.map((r) => [r.ok, na.text(r.resultText)])) + // The scenario must actually exercise refusals and successes + expect(a.results.some((r) => !r.ok)).toBe(true) + expect(a.results.filter((r) => r.ok).length).toBeGreaterThan(10) + + expect(nb.nodes).toEqual(na.nodes) + expect(nb.relations).toEqual(na.relations) + expect(nb.views).toEqual(na.views) + }) + + it('resets like the store does', () => { + useDiagramStore.getState().loadDiagram(blueprintDoc as unknown as DiagramData) + const start = useDiagramStore.getState().saveDiagram() + const headless = createModelFacade(start) + run(createStoreFacade(), [['reset_diagram', {}]]) + run(headless, [['reset_diagram', {}]]) + const saved = useDiagramStore.getState().saveDiagram() + const out = headless.toDiagramData() + expect([out.nodes, out.relations, out.views]).toEqual([saved.nodes, saved.relations, saved.views ?? []]) + expect(out.metamodel?.id).toBe(saved.metamodel?.id) + }) +}) diff --git a/tests/openFile.test.ts b/apps/studio/tests/openFile.test.ts similarity index 100% rename from tests/openFile.test.ts rename to apps/studio/tests/openFile.test.ts diff --git a/tests/route.test.ts b/apps/studio/tests/route.test.ts similarity index 100% rename from tests/route.test.ts rename to apps/studio/tests/route.test.ts diff --git a/tests/saveAsFile.test.ts b/apps/studio/tests/saveAsFile.test.ts similarity index 100% rename from tests/saveAsFile.test.ts rename to apps/studio/tests/saveAsFile.test.ts diff --git a/apps/studio/tests/setup.ts b/apps/studio/tests/setup.ts new file mode 100644 index 00000000..394d5856 --- /dev/null +++ b/apps/studio/tests/setup.ts @@ -0,0 +1,2 @@ +// The shared window / requestAnimationFrame stubs the diagram store needs under node. +import '@radical/ui/testing/setup' diff --git a/apps/studio/tests/setupPersistence.ts b/apps/studio/tests/setupPersistence.ts new file mode 100644 index 00000000..48cecc12 --- /dev/null +++ b/apps/studio/tests/setupPersistence.ts @@ -0,0 +1,4 @@ +// Studio's document persistence, as main.tsx installs it. A separate setup +// file because it must run after setup.ts has stubbed window: imports are +// evaluated before a module's own code. +import '../src/renderer/src/persistence/autosave' diff --git a/tests/viewerSandbox.test.ts b/apps/studio/tests/viewerSandbox.test.ts similarity index 98% rename from tests/viewerSandbox.test.ts rename to apps/studio/tests/viewerSandbox.test.ts index c5fbc262..e96d0358 100644 --- a/tests/viewerSandbox.test.ts +++ b/apps/studio/tests/viewerSandbox.test.ts @@ -19,9 +19,9 @@ * unlike during a live presentation */ import { describe, it, expect, beforeEach } from 'vitest' -import { useDiagramStore } from '../src/renderer/src/store/diagramStore' +import { useDiagramStore } from '@radical/ui/store/diagramStore' import { documents } from '../src/renderer/src/store/documentStore' -import type { C4Node, C4Relation } from '../src/renderer/src/types/c4' +import type { C4Node, C4Relation } from '@radical/common/c4' // The default node test env has no localStorage, so the document-persistence // layer is otherwise a no-op. Provide a minimal in-memory implementation so diff --git a/tests/webFolder.test.ts b/apps/studio/tests/webFolder.test.ts similarity index 100% rename from tests/webFolder.test.ts rename to apps/studio/tests/webFolder.test.ts diff --git a/tools/generate-sample.js b/apps/studio/tools/generate-sample.js similarity index 100% rename from tools/generate-sample.js rename to apps/studio/tools/generate-sample.js diff --git a/tools/sample-product.js b/apps/studio/tools/sample-product.js similarity index 100% rename from tools/sample-product.js rename to apps/studio/tools/sample-product.js diff --git a/tsconfig.json b/apps/studio/tsconfig.json similarity index 100% rename from tsconfig.json rename to apps/studio/tsconfig.json diff --git a/tsconfig.node.json b/apps/studio/tsconfig.node.json similarity index 100% rename from tsconfig.node.json rename to apps/studio/tsconfig.node.json diff --git a/tsconfig.web.json b/apps/studio/tsconfig.web.json similarity index 100% rename from tsconfig.web.json rename to apps/studio/tsconfig.web.json diff --git a/vite.web.config.ts b/apps/studio/vite.web.config.ts similarity index 63% rename from vite.web.config.ts rename to apps/studio/vite.web.config.ts index 465071f1..4fd87ada 100644 --- a/vite.web.config.ts +++ b/apps/studio/vite.web.config.ts @@ -3,11 +3,11 @@ * (no Electron shell). Output lands in out/renderer/ — same path as * electron-vite build so the GitHub Actions sync script just works. * - * Two HTML entries share one bundle: - * index.html → Radical Studio (studio.radical.tools) - * hub.html → Radical Hub (hub.radical.tools) — the embedded viewer + * index.html → Radical Studio (studio.radical.tools). The Hub viewer is its + * own app in apps/hub; this build still bundles the catalogue under hub/ so + * the studio's Hub import works offline and in development. * - * Usage: npm run build:web / npm run dev:web (then open /hub.html) + * Usage: npm run build:web / npm run dev:web * * Note: window.electronAPI calls in the code are all optional-chained * (?.readFile, ?.saveDiagram etc.) so the SPA gracefully degrades — @@ -17,23 +17,17 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { resolve } from 'path' -import { hubCataloguePlugin } from './tools/hubCatalogue' +import { hubCataloguePlugin } from '@radical/hub-catalogue/vite' const root = resolve(__dirname, 'src/renderer') export default defineConfig({ - plugins: [react(), hubCataloguePlugin({ hubDir: resolve(__dirname, 'hub') })], + plugins: [react(), hubCataloguePlugin()], root, base: './', build: { outDir: resolve(__dirname, 'out/renderer'), emptyOutDir: true, - rollupOptions: { - input: { - index: resolve(root, 'index.html'), - hub: resolve(root, 'hub.html'), - }, - }, }, resolve: { alias: { diff --git a/vitest.config.ts b/apps/studio/vitest.config.ts similarity index 69% rename from vitest.config.ts rename to apps/studio/vitest.config.ts index e6966782..775a6857 100644 --- a/vitest.config.ts +++ b/apps/studio/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - setupFiles: ['./tests/setup.ts'], + setupFiles: ['./tests/setup.ts', './tests/setupPersistence.ts'], include: ['tests/**/*.test.ts', 'tests/**/*.test.mjs'], }, }) diff --git a/tools/vscode-radical/.vscodeignore b/apps/vscode/.vscodeignore similarity index 100% rename from tools/vscode-radical/.vscodeignore rename to apps/vscode/.vscodeignore diff --git a/tools/vscode-radical/CHANGELOG.md b/apps/vscode/CHANGELOG.md similarity index 100% rename from tools/vscode-radical/CHANGELOG.md rename to apps/vscode/CHANGELOG.md diff --git a/tools/vscode-radical/README.md b/apps/vscode/README.md similarity index 100% rename from tools/vscode-radical/README.md rename to apps/vscode/README.md diff --git a/tools/vscode-radical/example/demo.c4.json b/apps/vscode/example/demo.c4.json similarity index 100% rename from tools/vscode-radical/example/demo.c4.json rename to apps/vscode/example/demo.c4.json diff --git a/tools/vscode-radical/example/demo.radical b/apps/vscode/example/demo.radical similarity index 100% rename from tools/vscode-radical/example/demo.radical rename to apps/vscode/example/demo.radical diff --git a/tools/vscode-radical/images/icon.png b/apps/vscode/images/icon.png similarity index 100% rename from tools/vscode-radical/images/icon.png rename to apps/vscode/images/icon.png diff --git a/tools/vscode-radical/language-configuration.json b/apps/vscode/language-configuration.json similarity index 100% rename from tools/vscode-radical/language-configuration.json rename to apps/vscode/language-configuration.json diff --git a/tools/vscode-radical/package.json b/apps/vscode/package.json similarity index 91% rename from tools/vscode-radical/package.json rename to apps/vscode/package.json index 55cec708..725d7585 100644 --- a/tools/vscode-radical/package.json +++ b/apps/vscode/package.json @@ -110,15 +110,18 @@ "scripts": { "compile": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --platform=node --format=cjs --sourcemap", "watch": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --platform=node --format=cjs --sourcemap --watch", - "bundle-webview": "node -e \"const fs=require('fs');fs.cpSync('../../out/renderer','webview',{recursive:true,force:true})\"", + "typecheck": "tsc --noEmit", + "bundle-webview": "node -e \"const {dirname,join}=require('path');const src=join(dirname(require.resolve('radical-model/package.json')),'out','renderer');require('fs').cpSync(src,'webview',{recursive:true,force:true})\"", "vscode:prepublish": "npm run compile", "package": "npm run compile && npx @vscode/vsce package --no-dependencies --allow-missing-repository", "publish": "npm run compile && npx @vscode/vsce publish --no-dependencies" }, "devDependencies": { + "@radical/host-bridge": "*", "@types/node": "^20.0.0", "@types/vscode": "^1.85.0", "@vscode/vsce": "^2.24.0", - "esbuild": "^0.20.0" + "esbuild": "^0.20.0", + "radical-model": "*" } } diff --git a/tools/vscode-radical/schemas/c4.schema.json b/apps/vscode/schemas/c4.schema.json similarity index 100% rename from tools/vscode-radical/schemas/c4.schema.json rename to apps/vscode/schemas/c4.schema.json diff --git a/tools/vscode-radical/src/extension.ts b/apps/vscode/src/extension.ts similarity index 86% rename from tools/vscode-radical/src/extension.ts rename to apps/vscode/src/extension.ts index 187758cc..ce3ebfff 100644 --- a/tools/vscode-radical/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode' import * as path from 'path' import * as cp from 'child_process' import * as fs from 'fs' +import { vscodeBootScript, type ExtensionMessage, type WebviewMessage } from '@radical/host-bridge/vscode' // ── State ───────────────────────────────────────────────────────────────────── @@ -83,13 +84,13 @@ class RadicalEditorProvider implements vscode.CustomTextEditorProvider { type: 'file:external-change', filePath, content: e.document.getText(), - }) + } satisfies ExtensionMessage) }) // File I/O bridge: messages from the webview - webviewPanel.webview.onDidReceiveMessage(async (msg: Record) => { + webviewPanel.webview.onDidReceiveMessage(async (msg: WebviewMessage) => { if (msg.type === 'writeFile') { - const content = msg.content as string + const content = msg.content entry.lastWrittenAt = Date.now() const edit = new vscode.WorkspaceEdit() const fullRange = new vscode.Range( @@ -104,7 +105,7 @@ class RadicalEditorProvider implements vscode.CustomTextEditorProvider { id: msg.id, success: true, content: document.getText(), - }) + } satisfies ExtensionMessage) } }) @@ -168,7 +169,10 @@ function getOutDir(projectRoot: string): string { const bundled = path.join(extensionPath, 'webview') if (fs.existsSync(path.join(bundled, 'index.html'))) return bundled } - // Fall back to local source build (developer install) + // Fall back to local source build (developer install): the monorepo keeps + // the Studio build in apps/studio/out, older checkouts at the root + const monorepoOut = path.join(projectRoot, 'apps', 'studio', 'out', 'renderer') + if (fs.existsSync(path.join(projectRoot, 'apps', 'studio'))) return monorepoOut return path.join(projectRoot, 'out', 'renderer') } @@ -324,16 +328,16 @@ async function openInWebview(filePath: string | null): Promise { if (Date.now() - entry.lastWrittenAt < 2000) return try { const content = fs.readFileSync(fp, 'utf-8') - panel.webview.postMessage({ type: 'file:external-change', filePath: fp, content }) + panel.webview.postMessage({ type: 'file:external-change', filePath: fp, content } satisfies ExtensionMessage) } catch { /* ignore */ } }) unwatchFn = () => fs.unwatchFile(fp) } - panel.webview.onDidReceiveMessage(async (msg: Record) => { + panel.webview.onDidReceiveMessage(async (msg: WebviewMessage) => { if (msg.type === 'writeFile') { - const fp = msg.filePath as string - const content = msg.content as string + const fp = msg.filePath + const content = msg.content try { entry.lastWrittenAt = Date.now() fs.writeFileSync(fp, content, 'utf-8') @@ -341,12 +345,12 @@ async function openInWebview(filePath: string | null): Promise { outputChannel.appendLine(`[Radical.Tools] writeFile error: ${err}`) } } else if (msg.type === 'readFile') { - const fp = msg.filePath as string + const fp = msg.filePath try { const content = fs.readFileSync(fp, 'utf-8') - panel.webview.postMessage({ type: 'readFile:response', id: msg.id, success: true, content }) + panel.webview.postMessage({ type: 'readFile:response', id: msg.id, success: true, content } satisfies ExtensionMessage) } catch (err) { - panel.webview.postMessage({ type: 'readFile:response', id: msg.id, success: false, error: String(err) }) + panel.webview.postMessage({ type: 'readFile:response', id: msg.id, success: false, error: String(err) } satisfies ExtensionMessage) } } }) @@ -386,8 +390,9 @@ function buildWebviewHtml( return `${attr}="${webview.asWebviewUri(vscode.Uri.file(abs))}"` }) - const filePathJson = JSON.stringify(filePath) - const polyfill = ` + // Studio's renderer builds its host bridge itself (createVsCodeWebviewHost in + // @radical/host-bridge/vscode); the page only needs the boot data. + const head = ` -` - - return html.replace('', '\n' + polyfill) +${vscodeBootScript({ filePath })}` + return html.replace('', '\n' + head) } diff --git a/tools/vscode-radical/tsconfig.json b/apps/vscode/tsconfig.json similarity index 60% rename from tools/vscode-radical/tsconfig.json rename to apps/vscode/tsconfig.json index 682ae7db..4543bb33 100644 --- a/tools/vscode-radical/tsconfig.json +++ b/apps/vscode/tsconfig.json @@ -1,14 +1,13 @@ { "compilerOptions": { - "module": "commonjs", + "module": "ESNext", "target": "ES2020", "lib": ["ES2020"], - "outDir": "dist", "strict": true, - "rootDir": "src", - "moduleResolution": "node", + "moduleResolution": "bundler", "esModuleInterop": true, - "skipLibCheck": true + "skipLibCheck": true, + "noEmit": true }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"] diff --git a/website/index.html b/apps/web/index.html similarity index 100% rename from website/index.html rename to apps/web/index.html diff --git a/website/landing.css b/apps/web/landing.css similarity index 100% rename from website/landing.css rename to apps/web/landing.css diff --git a/website/manual.html b/apps/web/manual.html similarity index 99% rename from website/manual.html rename to apps/web/manual.html index e006250e..09d7bbc1 100644 --- a/website/manual.html +++ b/apps/web/manual.html @@ -580,7 +580,8 @@

Desktop app

npm install npm run dev # Electron, hot reload npm run build && npm start -npm run dev:web # browser build: / (Studio) and /hub.html (Hub) +npm run dev:web # browser build of Studio +npm run dev:hub # Architecture Hub

The desktop app adds native file dialogs, Markdown folders without browser permissions and --file watching. Everything else is identical to the browser build.

diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 00000000..9054127e --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,5 @@ +{ + "name": "@radical/web", + "private": true, + "description": "Marketing site and manual (radical.tools). Static files, deployed as-is." +} diff --git a/website/screenshots/ai-settings-dark.png b/apps/web/screenshots/ai-settings-dark.png similarity index 100% rename from website/screenshots/ai-settings-dark.png rename to apps/web/screenshots/ai-settings-dark.png diff --git a/website/screenshots/ai-settings-light.png b/apps/web/screenshots/ai-settings-light.png similarity index 100% rename from website/screenshots/ai-settings-light.png rename to apps/web/screenshots/ai-settings-light.png diff --git a/website/screenshots/demo.gif b/apps/web/screenshots/demo.gif similarity index 100% rename from website/screenshots/demo.gif rename to apps/web/screenshots/demo.gif diff --git a/website/screenshots/designer-dark.png b/apps/web/screenshots/designer-dark.png similarity index 100% rename from website/screenshots/designer-dark.png rename to apps/web/screenshots/designer-dark.png diff --git a/website/screenshots/designer-light.png b/apps/web/screenshots/designer-light.png similarity index 100% rename from website/screenshots/designer-light.png rename to apps/web/screenshots/designer-light.png diff --git a/website/screenshots/documents-dark.png b/apps/web/screenshots/documents-dark.png similarity index 100% rename from website/screenshots/documents-dark.png rename to apps/web/screenshots/documents-dark.png diff --git a/website/screenshots/documents-light.png b/apps/web/screenshots/documents-light.png similarity index 100% rename from website/screenshots/documents-light.png rename to apps/web/screenshots/documents-light.png diff --git a/website/screenshots/flow-dark.png b/apps/web/screenshots/flow-dark.png similarity index 100% rename from website/screenshots/flow-dark.png rename to apps/web/screenshots/flow-dark.png diff --git a/website/screenshots/flow-light.png b/apps/web/screenshots/flow-light.png similarity index 100% rename from website/screenshots/flow-light.png rename to apps/web/screenshots/flow-light.png diff --git a/website/screenshots/hierarchy-dark.png b/apps/web/screenshots/hierarchy-dark.png similarity index 100% rename from website/screenshots/hierarchy-dark.png rename to apps/web/screenshots/hierarchy-dark.png diff --git a/website/screenshots/hierarchy-light.png b/apps/web/screenshots/hierarchy-light.png similarity index 100% rename from website/screenshots/hierarchy-light.png rename to apps/web/screenshots/hierarchy-light.png diff --git a/website/screenshots/hub-catalogue-dark.png b/apps/web/screenshots/hub-catalogue-dark.png similarity index 100% rename from website/screenshots/hub-catalogue-dark.png rename to apps/web/screenshots/hub-catalogue-dark.png diff --git a/website/screenshots/hub-catalogue-light.png b/apps/web/screenshots/hub-catalogue-light.png similarity index 100% rename from website/screenshots/hub-catalogue-light.png rename to apps/web/screenshots/hub-catalogue-light.png diff --git a/website/screenshots/hub-import-dark.png b/apps/web/screenshots/hub-import-dark.png similarity index 100% rename from website/screenshots/hub-import-dark.png rename to apps/web/screenshots/hub-import-dark.png diff --git a/website/screenshots/hub-import-light.png b/apps/web/screenshots/hub-import-light.png similarity index 100% rename from website/screenshots/hub-import-light.png rename to apps/web/screenshots/hub-import-light.png diff --git a/website/screenshots/hub-landing-dark.png b/apps/web/screenshots/hub-landing-dark.png similarity index 100% rename from website/screenshots/hub-landing-dark.png rename to apps/web/screenshots/hub-landing-dark.png diff --git a/website/screenshots/hub-landing-light.png b/apps/web/screenshots/hub-landing-light.png similarity index 100% rename from website/screenshots/hub-landing-light.png rename to apps/web/screenshots/hub-landing-light.png diff --git a/website/screenshots/matrix-dark.png b/apps/web/screenshots/matrix-dark.png similarity index 100% rename from website/screenshots/matrix-dark.png rename to apps/web/screenshots/matrix-dark.png diff --git a/website/screenshots/matrix-light.png b/apps/web/screenshots/matrix-light.png similarity index 100% rename from website/screenshots/matrix-light.png rename to apps/web/screenshots/matrix-light.png diff --git a/website/screenshots/metamodel-dark.png b/apps/web/screenshots/metamodel-dark.png similarity index 100% rename from website/screenshots/metamodel-dark.png rename to apps/web/screenshots/metamodel-dark.png diff --git a/website/screenshots/metamodel-light.png b/apps/web/screenshots/metamodel-light.png similarity index 100% rename from website/screenshots/metamodel-light.png rename to apps/web/screenshots/metamodel-light.png diff --git a/website/screenshots/milestones-dark.png b/apps/web/screenshots/milestones-dark.png similarity index 100% rename from website/screenshots/milestones-dark.png rename to apps/web/screenshots/milestones-dark.png diff --git a/website/screenshots/milestones-light.png b/apps/web/screenshots/milestones-light.png similarity index 100% rename from website/screenshots/milestones-light.png rename to apps/web/screenshots/milestones-light.png diff --git a/website/screenshots/mockups-dark.png b/apps/web/screenshots/mockups-dark.png similarity index 100% rename from website/screenshots/mockups-dark.png rename to apps/web/screenshots/mockups-dark.png diff --git a/website/screenshots/mockups-light.png b/apps/web/screenshots/mockups-light.png similarity index 100% rename from website/screenshots/mockups-light.png rename to apps/web/screenshots/mockups-light.png diff --git a/website/screenshots/presenter-dark.png b/apps/web/screenshots/presenter-dark.png similarity index 100% rename from website/screenshots/presenter-dark.png rename to apps/web/screenshots/presenter-dark.png diff --git a/website/screenshots/presenter-light.png b/apps/web/screenshots/presenter-light.png similarity index 100% rename from website/screenshots/presenter-light.png rename to apps/web/screenshots/presenter-light.png diff --git a/website/screenshots/properties-dark.png b/apps/web/screenshots/properties-dark.png similarity index 100% rename from website/screenshots/properties-dark.png rename to apps/web/screenshots/properties-dark.png diff --git a/website/screenshots/properties-light.png b/apps/web/screenshots/properties-light.png similarity index 100% rename from website/screenshots/properties-light.png rename to apps/web/screenshots/properties-light.png diff --git a/website/screenshots/search-dark.png b/apps/web/screenshots/search-dark.png similarity index 100% rename from website/screenshots/search-dark.png rename to apps/web/screenshots/search-dark.png diff --git a/website/screenshots/search-light.png b/apps/web/screenshots/search-light.png similarity index 100% rename from website/screenshots/search-light.png rename to apps/web/screenshots/search-light.png diff --git a/website/screenshots/table-dark.png b/apps/web/screenshots/table-dark.png similarity index 100% rename from website/screenshots/table-dark.png rename to apps/web/screenshots/table-dark.png diff --git a/website/screenshots/table-light.png b/apps/web/screenshots/table-light.png similarity index 100% rename from website/screenshots/table-light.png rename to apps/web/screenshots/table-light.png diff --git a/website/screenshots/viewer-dark.png b/apps/web/screenshots/viewer-dark.png similarity index 100% rename from website/screenshots/viewer-dark.png rename to apps/web/screenshots/viewer-dark.png diff --git a/website/screenshots/viewer-light.png b/apps/web/screenshots/viewer-light.png similarity index 100% rename from website/screenshots/viewer-light.png rename to apps/web/screenshots/viewer-light.png diff --git a/website/screenshots/views-dark.png b/apps/web/screenshots/views-dark.png similarity index 100% rename from website/screenshots/views-dark.png rename to apps/web/screenshots/views-dark.png diff --git a/website/screenshots/views-light.png b/apps/web/screenshots/views-light.png similarity index 100% rename from website/screenshots/views-light.png rename to apps/web/screenshots/views-light.png diff --git a/website/screenshots/welcome-dark.png b/apps/web/screenshots/welcome-dark.png similarity index 100% rename from website/screenshots/welcome-dark.png rename to apps/web/screenshots/welcome-dark.png diff --git a/website/screenshots/welcome-light.png b/apps/web/screenshots/welcome-light.png similarity index 100% rename from website/screenshots/welcome-light.png rename to apps/web/screenshots/welcome-light.png diff --git a/website/screenshots/wiki-dark.png b/apps/web/screenshots/wiki-dark.png similarity index 100% rename from website/screenshots/wiki-dark.png rename to apps/web/screenshots/wiki-dark.png diff --git a/website/screenshots/wiki-light.png b/apps/web/screenshots/wiki-light.png similarity index 100% rename from website/screenshots/wiki-light.png rename to apps/web/screenshots/wiki-light.png diff --git a/website/social-preview.png b/apps/web/social-preview.png similarity index 100% rename from website/social-preview.png rename to apps/web/social-preview.png diff --git a/website/style.css b/apps/web/style.css similarity index 100% rename from website/style.css rename to apps/web/style.css diff --git a/website/theme.js b/apps/web/theme.js similarity index 100% rename from website/theme.js rename to apps/web/theme.js diff --git a/docs/IMPROVEMENTS.md b/docs/IMPROVEMENTS.md index 3f2af0ad..bbe95180 100644 --- a/docs/IMPROVEMENTS.md +++ b/docs/IMPROVEMENTS.md @@ -76,135 +76,41 @@ Two layers of suggestions: **functional** (what the user sees / can do) and **te ## Part 2 — Technical / code-quality improvements -### High impact - -1. **Delete dead components** — verified unreferenced anywhere in source: - - [src/renderer/src/components/Sidebar.tsx](src/renderer/src/components/Sidebar.tsx) (158 lines) - - [src/renderer/src/components/ViewBar.tsx](src/renderer/src/components/ViewBar.tsx) (92 lines) - - [src/renderer/src/components/PropertiesPanel.tsx](src/renderer/src/components/PropertiesPanel.tsx) (224 lines) - - Plus orphaned CSS: [`.sidebar*` rules](src/renderer/src/index.css#L659-L710) and [`grid-area: viewbar`](src/renderer/src/index.css#L585). Keep `.sidebar-section` / `.sidebar-section-title` — still used by [RightPanel.tsx](src/renderer/src/components/RightPanel.tsx#L680-L681). - - ≈ 470 LOC + ~60 CSS lines, zero behaviour change. - -2. **Split `diagramStore.ts` (2474 lines)** into zustand slices in [src/renderer/src/store/](src/renderer/src/store): - - `modelSlice.ts` — `c4Nodes`, `c4Relations`, CRUD, selection, `toggleCollapse`. - - `viewSlice.ts` — `views`, `activeViewId`, `addNodeToView`. - - `layoutSlice.ts` — `runRadicalLayout`, `runColaLayout`, `runElkLayout`, `runReferenceLayout`, live-cola wiring. - - `snapshotSlice.ts` — milestones + diff/restore. - - `presentationSlice.ts` — slides, presentations, HUD nav, viewport capture/restore. - - `rfDerivationSlice.ts` — `rfNodes`/`rfEdges` build pipeline. - - Compose via slice pattern; public API stays identical. - -3. **Replace `(window as any).__rfGetViewport` etc.** with a typed canvas-API slice or React context exposing `useReactFlow()`. Removes most of the ~160 `any` casts and makes things testable. - -4. **`TimeTravelBar` cleanup** — after the latest change [TimeTravelBar.tsx](src/renderer/src/components/TimeTravelBar.tsx) only renders for `viewer` mode. Either rename to `ViewerSlideStrip.tsx` or reuse the new `PresenterDock` in `readOnly` mode and delete `TimeTravelBar`. - -### Medium impact - -5. **Modularise `index.css` (2079 lines)** — split into `tokens.css`, `layout.css`, `panels.css`, `presentation.css`, `nodes.css`. Vite handles multi-import; reduces merge conflicts. - -6. **Extract hooks from `Canvas.tsx` (627 lines)** — `useCanvasDnD`, `useViewportCapture`, `useCanvasKeyboard`. - -7. **Side-effects out of actions** — `setActiveView` and `toggleCollapse` embed sibling-separation / parent-fitting math. Extract pure helpers (`fitParentToChildren`, `separateSiblings`) into `layout/`; easier to unit test and reuse. - -8. **Persistence robustness** — schema-validate parsed JSON in `documentStore.ts`/persist layer; recover gracefully from corruption; wrap localStorage writes in try/catch (quota / private mode). - -### Low impact / polish - -9. **Stricter `tsconfig`** — enable `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`. Many `any` casts will surface as real refinements. - -10. **Tests for store** — `presentationSlice.test.ts` (slide add/remove/reorder/link integrity), `snapshotSlice.test.ts` (milestone diff/restore round-trip). - -11. **Memoisation in `RightPanel.tsx`** — `TreeNodeItem` runs several store selectors per node; large models re-render the whole tree. Build a children-by-parent map once per render. - -12. **Consolidate icons** — [PresentationBar.tsx](src/renderer/src/components/PresentationBar.tsx#L7-L60), [Toolbar.tsx](src/renderer/src/components/Toolbar.tsx), [RightPanel.tsx](src/renderer/src/components/RightPanel.tsx#L131-L175), [TimeTravelBar.tsx](src/renderer/src/components/TimeTravelBar.tsx#L4-L14) each define their own SVG icon set. Move to `components/icons.tsx`. - -13. **File-naming / split** — - - [PresentationBar.tsx](src/renderer/src/components/PresentationBar.tsx) now exports `PresenterHUD`, `SlidesColumn`, `PresenterDock`, `PresentationBar`. Consider a `presentation/` folder, one component per file. - - [RightPanel.tsx](src/renderer/src/components/RightPanel.tsx) (704 lines) actually exports both `LeftPanel` and `RightPanel`. Split. - -### Suggested execution order - -1. Delete dead components & CSS (5 min, zero risk). -2. Replace TimeTravelBar with read-only PresenterDock. -3. Split RightPanel.tsx + centralise icons. -4. Slice `diagramStore.ts` (biggest win, biggest care). -5. Replace `window.__rf*` with typed canvas-API slice. -6. Modularise CSS. -7. Strict TS + store tests. - ---- - -## Appendix - Dynamic views (current usage) - -Current status: - -- The dynamic-view work is currently a domain/test foundation, not a finished editor feature. -- There is no dedicated UI, store CRUD, or renderer for sequence views yet. -- Today you use it by constructing `DiagramData` with `contexts` and `dynamicViews`, then calling the helper functions from [src/renderer/src/dynamicViews.ts](src/renderer/src/dynamicViews.ts). - -Data model: - -- Add business/journey/scenario definitions in `contexts`. -- Tag participating nodes and relations with `contextIds`. -- Define a `dynamicViews` entry with: - - `contextId` - the business context anchoring the sequence - - `viewId` - optional structural view scope - - `lifelineOrder` - explicit participant order - - `steps` - ordered interactions between existing nodes - -Core types live in [src/renderer/src/types/c4.ts](src/renderer/src/types/c4.ts). - -Minimal shape: - - { - "contexts": [ - { "id": "checkout", "name": "Checkout Journey", "kind": "journey" } - ], - "nodes": [ - { "id": "user", "label": "User", "type": "person", "collapsed": false, "x": 0, "y": 0, "width": 100, "height": 100, "contextIds": ["checkout"] }, - { "id": "api", "label": "API", "type": "container", "collapsed": false, "x": 0, "y": 0, "width": 100, "height": 100, "contextIds": ["checkout"] } - ], - "relations": [ - { "id": "r1", "sourceId": "user", "targetId": "api", "label": "starts checkout", "contextIds": ["checkout"] } - ], - "dynamicViews": [ - { - "id": "dv-checkout", - "name": "Checkout Payment", - "contextId": "checkout", - "lifelineOrder": ["user", "api"], - "steps": [ - { - "id": "s1", - "seq": "1", - "fromId": "user", - "toId": "api", - "relationId": "r1", - "label": "Start checkout", - "kind": "sync" - } - ] - } - ] - } - -Helper flow: - -1. `normalizeDynamicViewCollections(data)` - Use this first to normalize optional fields, remove duplicate tags/lifelines, and fill nullable values. -2. `getContextualElements({ nodes, relations }, contextId)` - Use this to fetch only the model slice relevant to a given business context. -3. `validateDynamicView(view, { contexts, nodes, relations, views })` - Use this to validate a sequence against the live structural model. - -Validation rules currently check: - -- the context exists -- the scoped structural view exists, when provided -- lifeline nodes exist and are unique -- every step source/target exists and is present in `lifelineOrder` -- optional `relationId` exists and matches the step endpoints -- warnings when lifelines or relations are outside the selected context -- warnings when lifelines are outside the scoped structural view - -Best working example: [tests/dynamicViews.test.ts](tests/dynamicViews.test.ts). +Status as of 2026-09-29, after the monorepo restructuring (branch +`refactor/monorepo-workspaces`). Paths are relative to the repo root. + +### Done + +- **Dead components and CSS removed.** Sidebar, ViewBar, PropertiesPanel, + TimeTravelBar, ExportMenu, DevSampleToolbar and PresentationBar's SlidesColumn + are gone, along with 182 CSS rules for UI that no longer exists. +- **CSS split by owner.** `packages/ui/src/index.css` holds the shared canvas, + panel and view styles; `apps/studio/src/renderer/src/studio.css` holds + Studio-only UI. +- **Side effects out of store actions, partly.** The model rules and edits + behind node, relation and view changes live in `packages/common/src/model.ts` + and are shared with a headless facade. Persistence moved out of the store + into `apps/studio/src/renderer/src/persistence/`. +- **Store tests.** The diagram-store tests live in `packages/ui/tests` and run + without any app's persistence. + +### Still open + +1. **Split `diagramStore.ts`** (`packages/ui/src/store/diagramStore.ts`, + 4383 lines) into zustand slices: model, views, layout, snapshots, + presentation, and ReactFlow derivation. The public API stays identical. +2. **Replace `(window as any).__rf*` globals** (13 in the store) with a typed + canvas API or React context. This removes most of the store's 155 + `as any` casts and makes the canvas plumbing testable. +3. **Extract hooks from `Canvas.tsx`** (`packages/ui/src/components/Canvas.tsx`, + 1021 lines): `useCanvasDnD`, `useViewportCapture`, `useCanvasKeyboard`. +4. **Split `RightPanel.tsx`** (2185 lines). It exports both `LeftPanel` and + `RightPanel`. Memoise the tree: `TreeNodeItem` runs several store + selectors per node. +5. **Consolidate icons.** PresentationBar, Toolbar and RightPanel each define + their own SVG icon set. +6. **Persistence robustness.** Schema-validate documents on load in + `apps/studio/src/renderer/src/store/documentStore.ts`. +7. **Stricter `tsconfig`**: `noUncheckedIndexedAccess`, + `exactOptionalPropertyTypes`. Type-check the tests that `apps/studio` and + `apps/hub` still leave out. diff --git a/package-lock.json b/package-lock.json index 16a424b1..61dc5e82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,61 @@ { - "name": "radical-model", + "name": "radical", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { + "name": "radical", + "license": "MIT", + "workspaces": [ + "packages/*", + "apps/*" + ], + "devDependencies": { + "typescript": "^5.3.3" + } + }, + "apps/hub": { + "name": "@radical/hub", + "version": "0.0.0", + "dependencies": { + "@radical/common": "*", + "@radical/ui": "*", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "reactflow": "^11.11.4" + }, + "devDependencies": { + "@radical/hub-catalogue": "*", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.0.10", + "vitest": "^4.1.4" + } + }, + "apps/mcp": { + "name": "@radical/mcp", + "version": "0.0.0", + "dependencies": { + "@radical/common": "*", + "@radical/layout": "*" + }, + "devDependencies": { + "typescript": "^5.3.3" + } + }, + "apps/studio": { "name": "radical-model", "version": "1.0.0", "license": "MIT", "dependencies": { "@electron-toolkit/utils": "^2.0.1", - "elkjs": "^0.9.3", + "d3-dispatch": "^3.0.1", + "d3-drag": "^3.0.0", + "d3-timer": "^3.0.1", "html-to-image": "^1.11.13", - "immer": "^10.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", "reactflow": "^11.11.4", @@ -20,18 +63,650 @@ "zustand": "^4.5.0" }, "devDependencies": { + "@radical/common": "*", + "@radical/host-bridge": "*", + "@radical/hub-catalogue": "*", + "@radical/ui": "*", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.1", - "electron": "^29.0.0", + "electron": "29.4.6", "electron-builder": "^26.15.3", "electron-vite": "^2.0.0", + "elkjs": "^0.9.3", "tsx": "^4.21.0", "typescript": "^5.3.3", "vite": "^5.0.10", "vitest": "^4.1.4" } }, + "apps/vscode": { + "name": "vscode-radical", + "version": "0.2.0", + "license": "MIT", + "devDependencies": { + "@radical/host-bridge": "*", + "@types/node": "^20.0.0", + "@types/vscode": "^1.85.0", + "@vscode/vsce": "^2.24.0", + "esbuild": "^0.20.0", + "radical-model": "*" + }, + "engines": { + "vscode": "^1.85.0" + } + }, + "apps/vscode/node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "apps/vscode/node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "apps/web": { + "name": "@radical/web" + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.2.tgz", + "integrity": "sha512-1D2LpsU7y9xrqKjdIbsB7PlrRePw0xsVV8p+AKTlzITrWmscajryfJCdDJB/oGwvDI5HmRo04eMMADB67uwAwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.24.0.tgz", + "integrity": "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.23.0.tgz", + "integrity": "sha512-IRCwkRCK47hBXK+7bu67UZWY7HS0Jx1dZgi4C1HVbjlBum8Jy8fb2l87xFr6HxdQJWy951zRUHtwwDITHFAtaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.14.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.14.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.1.tgz", + "integrity": "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.6.0.tgz", + "integrity": "sha512-uFY9NxrWHw8PwZx7gAX6PDn+9vdfS05+levc/kwkx77IkjfaldnQbbcQzzDIZ5Hq5Zdr6/z92oAIoRWKp6MnOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.13.0", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { + "version": "16.13.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.13.0.tgz", + "integrity": "sha512-rOAy0KUcyBbdwVJ+f3uPpthXatFLLZN+/KWAsTLzk1aB23Xl9DRmmXYwSvBFOZyXj4jUQQ5FKxxRkhAFW1fOow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1484,6 +2159,38 @@ "node": ">=14.18.0" } }, + "node_modules/@radical/common": { + "resolved": "packages/common", + "link": true + }, + "node_modules/@radical/host-bridge": { + "resolved": "packages/host-bridge", + "link": true + }, + "node_modules/@radical/hub": { + "resolved": "apps/hub", + "link": true + }, + "node_modules/@radical/hub-catalogue": { + "resolved": "packages/hub-catalogue", + "link": true + }, + "node_modules/@radical/layout": { + "resolved": "packages/layout", + "link": true + }, + "node_modules/@radical/mcp": { + "resolved": "apps/mcp", + "link": true + }, + "node_modules/@radical/ui": { + "resolved": "packages/ui", + "link": true + }, + "node_modules/@radical/web": { + "resolved": "apps/web", + "link": true + }, "node_modules/@reactflow/background": { "version": "11.3.14", "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", @@ -2671,6 +3378,13 @@ "@types/node": "*" } }, + "node_modules/@types/vscode": { + "version": "1.138.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.138.0.tgz", + "integrity": "sha512-DhlvrucJa8n6EHst7qQcXOcjLKs/VRTQy55plLO+RN74jonYsr/t9dd66zBnAyz1tpDig0okQAxuoPHpVNje2g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -2681,111 +3395,458 @@ "@types/node": "*" } }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.6.tgz", + "integrity": "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vscode/vsce": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-2.32.0.tgz", + "integrity": "sha512-3EFJfsgrSftIqt3EtdRcAygy/OJ3hstyI1cDmIgkU9CFZW5C+3djr6mfosndCUqcVYuyjmxOK1xmFp/Bq7+NIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^2.4.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^6.2.1", + "form-data": "^4.0.0", + "glob": "^7.0.6", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^12.3.2", + "mime": "^1.3.4", + "minimatch": "^3.0.3", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "semver": "^7.5.2", + "tmp": "^0.2.1", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^2.3.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 16" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.1.0.tgz", + "integrity": "sha512-9AQrqazrBgTgRSuwleLVXUrIUphY02/SFCh2TKYoLV/xifJAdblhdmEmw5gUrYSPQ3sRwNs9iyCMD14sATEE6g==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.6", + "@vscode/vsce-sign-alpine-x64": "2.0.6", + "@vscode/vsce-sign-darwin-arm64": "2.0.6", + "@vscode/vsce-sign-darwin-x64": "2.0.6", + "@vscode/vsce-sign-linux-arm": "2.0.6", + "@vscode/vsce-sign-linux-arm64": "2.0.6", + "@vscode/vsce-sign-linux-x64": "2.0.6", + "@vscode/vsce-sign-win32-arm64": "2.0.6", + "@vscode/vsce-sign-win32-x64": "2.0.6" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", + "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", + "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", + "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@vscode/vsce/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vscode/vsce/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@vscode/vsce/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@vscode/vsce/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@vscode/vsce/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vscode/vsce/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "license": "MIT", "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "node": ">= 6" } }, - "node_modules/@vitest/expect": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", - "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "node_modules/@vscode/vsce/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=0.8.0" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", - "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "node_modules/@vscode/vsce/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=4" } }, - "node_modules/@vitest/runner": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", - "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "node_modules/@vscode/vsce/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.4", - "pathe": "^2.0.3" + "bin": { + "mime": "cli.js" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=4" } }, - "node_modules/@vitest/snapshot": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", - "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "node_modules/@vscode/vsce/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@vitest/pretty-format": "4.1.4", - "@vitest/utils": "4.1.4", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" + "brace-expansion": "^1.1.7" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": "*" } }, - "node_modules/@vitest/spy": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", - "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "node_modules/@vscode/vsce/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, - "node_modules/@vitest/utils": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", - "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "node_modules/@vscode/vsce/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" + "has-flag": "^3.0.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=4" } }, "node_modules/@xmldom/xmldom": { @@ -3105,6 +4166,17 @@ "dev": true, "license": "MIT" }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -3149,6 +4221,35 @@ "node": ">=6.0.0" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -3156,6 +4257,13 @@ "dev": true, "license": "MIT" }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -3211,6 +4319,32 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -3220,6 +4354,13 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3305,6 +4446,22 @@ "node": ">= 10.0.0" } }, + "node_modules/bundle-name": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bytestreamjs": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", @@ -3366,6 +4523,23 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001788", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", @@ -3414,6 +4588,60 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -3480,6 +4708,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3601,6 +4839,36 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3772,6 +5040,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -3793,10 +5102,23 @@ "gopd": "^1.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/define-properties": { @@ -3937,6 +5259,65 @@ "node": ">= 10.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -3991,6 +5372,16 @@ "readable-stream": "^2.0.2" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -4246,6 +5637,20 @@ "dev": true, "license": "MIT" }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -4255,6 +5660,19 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -4406,6 +5824,17 @@ "@types/estree": "^1.0.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -4551,6 +5980,14 @@ "node": ">= 6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -4576,7 +6013,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4675,7 +6111,6 @@ "version": "4.14.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "dev": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -4684,6 +6119,14 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -4933,6 +6376,39 @@ "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", "license": "MIT" }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -4980,6 +6456,41 @@ "node": ">= 14" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", @@ -5009,6 +6520,30 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -5019,6 +6554,41 @@ "node": ">=8" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -5152,6 +6722,13 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -5161,6 +6738,78 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5177,6 +6826,16 @@ "dev": true, "license": "MIT" }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -5425,23 +7084,82 @@ "x64" ], "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linkify-it": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", + "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "dev": true, "license": "MIT" }, @@ -5486,6 +7204,33 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-it": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", + "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "~2.1.0", + "linkify-it": "^3.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -5509,6 +7254,13 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "dev": true, + "license": "MIT" + }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -5617,12 +7369,27 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5642,6 +7409,14 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-abi": { "version": "4.35.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.35.0.tgz", @@ -5668,6 +7443,14 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", @@ -5797,6 +7580,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -5827,6 +7636,25 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -5852,6 +7680,79 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.1.0" + } + }, + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -6028,6 +7929,63 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/node-abi": { + "version": "3.96.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz", + "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -6110,6 +8068,23 @@ "node": ">=16.0.0" } }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -6122,6 +8097,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/radical-model": { + "resolved": "apps/studio", + "link": true + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -6175,6 +8171,19 @@ "react-dom": ">=17" } }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -6252,7 +8261,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -6399,6 +8407,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -6406,6 +8427,13 @@ "dev": true, "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/sanitize-filename": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", @@ -6456,38 +8484,114 @@ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", "license": "MIT", - "optional": true, + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", "dependencies": { - "type-fest": "^0.13.1" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/siginfo": { @@ -6504,6 +8608,55 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -6630,6 +8783,17 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -6672,6 +8836,62 @@ "node": ">=18" } }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -6851,7 +9071,6 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", @@ -6874,7 +9093,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6891,7 +9109,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6908,7 +9125,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6925,7 +9141,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6942,7 +9157,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6959,7 +9173,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6976,7 +9189,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6993,7 +9205,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7010,7 +9221,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7027,7 +9237,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7044,7 +9253,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7061,7 +9269,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7078,7 +9285,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7095,7 +9301,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7112,7 +9317,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7129,7 +9333,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7146,7 +9349,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7163,7 +9365,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7180,7 +9381,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7197,7 +9397,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7214,7 +9413,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7231,7 +9429,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7248,7 +9445,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7265,7 +9461,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7282,7 +9477,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7299,7 +9493,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7313,7 +9506,6 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -7351,6 +9543,30 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -7364,6 +9580,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -7378,6 +9606,20 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, "node_modules/undici": { "version": "6.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", @@ -7486,6 +9728,13 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -8222,6 +10471,10 @@ } } }, + "node_modules/vscode-radical": { + "resolved": "apps/vscode", + "link": true + }, "node_modules/webcola": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/webcola/-/webcola-3.4.0.tgz", @@ -8276,6 +10529,30 @@ "tslib": "^2.8.1" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", @@ -8333,6 +10610,46 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -8399,6 +10716,16 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -8439,6 +10766,71 @@ "optional": true } } + }, + "packages/common": { + "name": "@radical/common", + "version": "0.0.0", + "devDependencies": { + "typescript": "^5.3.3", + "vitest": "^4.1.4" + } + }, + "packages/host-bridge": { + "name": "@radical/host-bridge", + "version": "0.0.0", + "devDependencies": { + "typescript": "^5.3.3", + "vitest": "^4.1.4" + } + }, + "packages/hub-catalogue": { + "name": "@radical/hub-catalogue", + "version": "0.0.0", + "dependencies": { + "@radical/common": "*", + "tsx": "^4.21.0" + }, + "devDependencies": { + "@types/node": "^20.19.39", + "typescript": "^5.3.3", + "vite": "^5.0.10", + "vitest": "^4.1.4" + } + }, + "packages/layout": { + "name": "@radical/layout", + "version": "0.0.0", + "dependencies": { + "@radical/common": "*", + "elkjs": "^0.9.3" + }, + "devDependencies": { + "typescript": "^5.3.3", + "vitest": "^4.1.4" + } + }, + "packages/ui": { + "name": "@radical/ui", + "version": "0.0.0", + "dependencies": { + "@radical/common": "*", + "@radical/layout": "*", + "d3-dispatch": "^3.0.1", + "d3-drag": "^3.0.0", + "d3-timer": "^3.0.1", + "immer": "^10.0.3", + "react": "^18.2.0", + "reactflow": "^11.11.4", + "webcola": "^3.4.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "typescript": "^5.3.3", + "vite": "^5.0.10", + "vitest": "^4.1.4" + } } } } diff --git a/package.json b/package.json index d7897bcb..20cee19f 100644 --- a/package.json +++ b/package.json @@ -1,47 +1,32 @@ { - "name": "radical-model", - "version": "1.0.0", - "description": "C4 Architecture Diagram Tool", - "author": "radical.tools contributors", + "name": "radical", + "private": true, + "description": "radical.tools monorepo: Studio, Hub, VS Code extension, website and shared packages", "license": "MIT", "homepage": "https://radical.tools", "repository": { "type": "git", "url": "https://github.com/radicaltools/radical.tools.git" }, - "main": "out/main/index.js", + "workspaces": [ + "packages/*", + "apps/*" + ], "scripts": { - "dev": "electron-vite dev", - "build": "electron-vite build", - "build:web": "vite build --config vite.web.config.ts", - "dev:web": "vite --config vite.web.config.ts", - "start": "electron-vite preview", - "dist": "npm run build && electron-builder --publish never", - "test": "vitest run", - "test:watch": "vitest", - "typecheck": "tsc -p tsconfig.web.json --noEmit && tsc -p tsconfig.node.json --noEmit" - }, - "dependencies": { - "@electron-toolkit/utils": "^2.0.1", - "elkjs": "^0.9.3", - "html-to-image": "^1.11.13", - "immer": "^10.0.3", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "reactflow": "^11.11.4", - "webcola": "^3.4.0", - "zustand": "^4.5.0" + "dev": "npm run dev -w radical-model", + "dev:web": "npm run dev:web -w radical-model", + "dev:hub": "npm run dev -w @radical/hub", + "build": "npm run build -w radical-model", + "build:web": "npm run build:web -w radical-model", + "build:hub": "npm run build -w @radical/hub", + "start": "npm run start -w radical-model", + "dist": "npm run dist -w radical-model", + "test": "npm run test --workspaces --if-present", + "test:watch": "npm run test:watch -w radical-model", + "typecheck": "npm run typecheck --workspaces --if-present", + "check:workspaces": "node tools/check-workspaces.mjs" }, "devDependencies": { - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", - "@vitejs/plugin-react": "^4.2.1", - "electron": "^29.0.0", - "electron-builder": "^26.15.3", - "electron-vite": "^2.0.0", - "tsx": "^4.21.0", - "typescript": "^5.3.3", - "vite": "^5.0.10", - "vitest": "^4.1.4" + "typescript": "^5.3.3" } } diff --git a/packages/common/README.md b/packages/common/README.md new file mode 100644 index 00000000..f4a0217e --- /dev/null +++ b/packages/common/README.md @@ -0,0 +1,22 @@ +# @radical/common + +The radical model, shared by Studio, the VS Code extension and the MCP server. + +| Import | What it holds | +|---|---| +| `@radical/common/c4` | `DiagramData`, `C4Node`, `C4Relation`, views, node sizes | +| `@radical/common/metamodel` | Metamodel types, built-in presets (C4, DDD, Governance), validation, EARS | +| `@radical/common/model` | The rules and edits behind every model change (add / update / remove nodes, relations, views), shared by Studio's store and the headless facade | +| `@radical/common/hubFormat` | Hub concept documents and the catalogue index format | +| `@radical/common/formats/*` | Structurizr DSL import, markdown-folder persistence, Gherkin export | +| `@radical/common/ai/*` | Query language, `DiagramFacade`, the AI tool catalogue (`ToolDef` + handlers) and `createModelFacade`, which runs those tools on a plain `DiagramData` with no UI | + +Rules: + +- No UI, browser or store dependencies. The package type-checks without the + DOM lib, so `window` or `document` here fails `npm run typecheck`. +- AI tools are written against `DiagramFacade`, not against Studio's store, + so the MCP server can serve the same tools over a headless model. +- Consumers import `.ts` source directly; there is no build step. +- Only the subpaths listed in `exports` in package.json are public. Tests + import internals by relative path. diff --git a/packages/common/package.json b/packages/common/package.json new file mode 100644 index 00000000..53ed1254 --- /dev/null +++ b/packages/common/package.json @@ -0,0 +1,29 @@ +{ + "name": "@radical/common", + "version": "0.0.0", + "private": true, + "description": "The radical model shared by Studio, the VS Code extension and the MCP server: C4 types, metamodels, file formats, query language and AI tool definitions. No UI or browser dependencies.", + "type": "module", + "exports": { + "./c4": "./src/c4.ts", + "./metamodel": "./src/metamodel/index.ts", + "./model": "./src/model.ts", + "./hubFormat": "./src/hubFormat.ts", + "./wireframe": "./src/wireframe.ts", + "./formats/structurizrDsl": "./src/formats/structurizrDsl.ts", + "./formats/mdFolder": "./src/formats/mdFolder.ts", + "./formats/exportGherkin": "./src/formats/exportGherkin.ts", + "./ai/queryLanguage": "./src/ai/queryLanguage.ts", + "./ai/diagramFacade": "./src/ai/diagramFacade.ts", + "./ai/modelFacade": "./src/ai/modelFacade.ts", + "./ai/tools": "./src/ai/tools/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "typescript": "^5.3.3", + "vitest": "^4.1.4" + } +} diff --git a/src/renderer/src/ai/diagramFacade.ts b/packages/common/src/ai/diagramFacade.ts similarity index 96% rename from src/renderer/src/ai/diagramFacade.ts rename to packages/common/src/ai/diagramFacade.ts index 1beeacd4..21d79ee4 100644 --- a/src/renderer/src/ai/diagramFacade.ts +++ b/packages/common/src/ai/diagramFacade.ts @@ -4,8 +4,8 @@ // applyPatch.ts — there is no single "apply a whole patch" function anymore, // each tool applies its own single change. -import type { C4Node, C4Relation, DiagramView } from '../types/c4' -import type { Metamodel } from '../types/metamodel' +import type { C4Node, C4Relation, DiagramView } from '../c4' +import type { Metamodel } from '../metamodel' export interface ApplyReport { added: { nodes: number; relations: number; views: number } diff --git a/packages/common/src/ai/modelFacade.ts b/packages/common/src/ai/modelFacade.ts new file mode 100644 index 00000000..ac3139b4 --- /dev/null +++ b/packages/common/src/ai/modelFacade.ts @@ -0,0 +1,129 @@ +// ─── Headless diagram facade ───────────────────────────────────────────────── +// +// A DiagramFacade over a plain DiagramData document, with no store or UI, so +// the AI tool catalogue (./tools) can edit a model outside Studio (the MCP +// server). It applies the same metamodel rules and edits as Studio's store, +// through ../model. +// +// What it does not do, because it has no canvas: undo, notifications, +// per-view node positions, and fitting a parent's size around a new child. +// Lay the result out with @radical/layout if positions matter. + +import type { C4Node, C4Relation, DiagramData, DiagramView } from '../c4' +import * as model from '../model' +import type { DiagramFacade } from './diagramFacade' + +export interface ModelFacade extends DiagramFacade { + /** The edited model as a document. Fields the facade does not edit + * (sequences, snapshots, presentations, …) are carried over from the input. */ + toDiagramData(): DiagramData + /** Why the most recent refused change was refused, or null. */ + readonly lastError: string | null +} + +export interface ModelFacadeOptions { + /** Id generator for new nodes, relations and views. Defaults to crypto.randomUUID. */ + newId?: () => string +} + +const randomId = (): string => + (globalThis as unknown as { crypto: { randomUUID(): string } }).crypto.randomUUID() + +const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T + +const byId = (items: T[] | undefined): Record => + Object.fromEntries((items ?? []).map((item) => [item.id, clone(item)])) + +export function createModelFacade(data: DiagramData, options: ModelFacadeOptions = {}): ModelFacade { + const newId = options.newId ?? randomId + const { nodes, relations, views, ...rest } = data + let carried: Omit = clone(rest) + const state: model.ModelState = { + c4Nodes: byId(nodes), + c4Relations: byId(relations), + views: byId(views), + activeViewId: null, + metamodel: model.documentMetamodel(data.metamodel), + } + let lastError: string | null = null + const refuse = (reason: string): void => { lastError = reason } + + return { + get lastError() { return lastError }, + + getNodes: () => state.c4Nodes, + getRelations: () => state.c4Relations, + getMetamodel: () => state.metamodel, + getViews: () => state.views, + getActiveView: () => { + const view = state.activeViewId ? state.views[state.activeViewId] : undefined + return view ? { id: view.id, name: view.name, nodeIds: view.nodeIds } : null + }, + + addNode(node: Omit) { + const refused = model.checkAddNode(state, node) + if (refused) { refuse(refused); return '' } + const id = newId() + model.insertNode(state, id, node) + return id + }, + updateNode(id: string, updates: Partial>) { + if (!state.c4Nodes[id]) return + const refused = model.checkNodeUpdate(state, id, updates) + if (refused) { refuse(refused); return } + model.patchNode(state, id, updates) + }, + removeNode(id: string) { + model.deleteNode(state, id) + }, + + addRelation(rel: Omit) { + const refused = model.checkAddRelation(state, rel) + if (refused) { refuse(refused); return } + model.insertRelation(state, newId(), rel) + }, + updateRelation(id: string, updates: Partial>) { + model.patchRelation(state, id, updates) + }, + removeRelation(id: string) { + model.deleteRelation(state, id) + }, + + addView(name: string) { + const id = newId() + model.insertView(state, id, name) + return id + }, + setViewNodes(viewId: string, nodeIds: string[]) { + model.setViewNodeIds(state, viewId, nodeIds) + }, + removeView(id: string) { + delete state.views[id] + if (state.activeViewId === id) state.activeViewId = null + }, + setActiveView(id: string | null) { + state.activeViewId = id + }, + setViewKind(id: string, kind: DiagramView['kind']) { + model.setViewKind(state, id, kind) + }, + + // Same as Studio's reset: everything goes except the metamodel. + clearDiagram() { + state.c4Nodes = {} + state.c4Relations = {} + state.views = {} + state.activeViewId = null + carried = carried.metamodel ? { metamodel: carried.metamodel } : {} + }, + + toDiagramData(): DiagramData { + return clone({ + ...carried, + nodes: Object.values(state.c4Nodes), + relations: Object.values(state.c4Relations), + views: Object.values(state.views), + }) + }, + } +} diff --git a/src/renderer/src/ai/queryLanguage.ts b/packages/common/src/ai/queryLanguage.ts similarity index 99% rename from src/renderer/src/ai/queryLanguage.ts rename to packages/common/src/ai/queryLanguage.ts index d26eb6dc..a3ab6e51 100644 --- a/src/renderer/src/ai/queryLanguage.ts +++ b/packages/common/src/ai/queryLanguage.ts @@ -1,4 +1,4 @@ -import type { C4Node, C4Relation, DiagramView } from '../types/c4' +import type { C4Node, C4Relation, DiagramView } from '../c4' export const MODEL_QUERY_LANGUAGE_HELP = `Query the CURRENT diagram structure with a small built-in query language — use this whenever you need exact data before answering or acting, instead of diff --git a/src/renderer/src/ai/tools/index.ts b/packages/common/src/ai/tools/index.ts similarity index 85% rename from src/renderer/src/ai/tools/index.ts rename to packages/common/src/ai/tools/index.ts index adcc3129..39145b59 100644 --- a/src/renderer/src/ai/tools/index.ts +++ b/packages/common/src/ai/tools/index.ts @@ -3,13 +3,12 @@ // custom type" promise as TableView.tsx's deriveNodeCols/propToCol — so a // type added via the Metamodel Editor gets full AI support automatically. -import type { Metamodel } from '../../types/metamodel' -import type { ToolDef } from '../types' +import type { Metamodel } from '../../metamodel' import { buildModelToolDefs, buildModelToolHandlers } from './modelTools' import { buildNodeToolDefs, buildNodeToolHandlers } from './nodeTools' import { buildRelationToolDefs, buildRelationToolHandlers } from './relationTools' import { buildViewToolDefs, buildViewToolHandlers } from './viewTools' -import type { ToolHandler } from './types' +import type { ToolDef, ToolHandler } from './types' export function buildToolDefs(metamodel: Metamodel | undefined): ToolDef[] { return [ @@ -29,4 +28,4 @@ export function buildToolHandlers(): Map { })) } -export type { ToolHandler, ToolRunContext, ToolResult } from './types' +export type { ToolDef, ToolHandler, ToolRunContext, ToolResult } from './types' diff --git a/src/renderer/src/ai/tools/modelTools.ts b/packages/common/src/ai/tools/modelTools.ts similarity index 96% rename from src/renderer/src/ai/tools/modelTools.ts rename to packages/common/src/ai/tools/modelTools.ts index 14d6c9b4..a47c1176 100644 --- a/src/renderer/src/ai/tools/modelTools.ts +++ b/packages/common/src/ai/tools/modelTools.ts @@ -1,8 +1,7 @@ // ─── Model tools: search_model / focus_node / reset_diagram ───────────────── import { MODEL_QUERY_LANGUAGE_HELP, runModelQuery } from '../queryLanguage' -import type { ToolDef } from '../types' -import { fail, type ToolHandler } from './types' +import { fail, type ToolDef, type ToolHandler } from './types' export function buildModelToolDefs(): ToolDef[] { return [ diff --git a/src/renderer/src/ai/tools/nodeTools.ts b/packages/common/src/ai/tools/nodeTools.ts similarity index 97% rename from src/renderer/src/ai/tools/nodeTools.ts rename to packages/common/src/ai/tools/nodeTools.ts index 580fb7c7..52073b43 100644 --- a/src/renderer/src/ai/tools/nodeTools.ts +++ b/packages/common/src/ai/tools/nodeTools.ts @@ -1,9 +1,8 @@ // ─── Node tools: add_node / update_node / delete_node ─────────────────────── -import { NODE_SIZES, type C4ElementType, type C4Node } from '../../types/c4' -import type { Metamodel } from '../../types/metamodel' -import type { ToolDef } from '../types' -import { fail, type ToolHandler } from './types' +import { NODE_SIZES, type C4ElementType, type C4Node } from '../../c4' +import type { Metamodel } from '../../metamodel' +import { fail, type ToolDef, type ToolHandler } from './types' import { validateProperties } from './propertyBag' export function buildNodeToolDefs(mm: Metamodel | undefined): ToolDef[] { diff --git a/src/renderer/src/ai/tools/propertyBag.ts b/packages/common/src/ai/tools/propertyBag.ts similarity index 97% rename from src/renderer/src/ai/tools/propertyBag.ts rename to packages/common/src/ai/tools/propertyBag.ts index 3ea6d670..0dc79e92 100644 --- a/src/renderer/src/ai/tools/propertyBag.ts +++ b/packages/common/src/ai/tools/propertyBag.ts @@ -5,7 +5,7 @@ // applyPatch.ts had: a governance/custom property (ADR status, Requirement // ears_type, ...) can now reach the store. -import type { PropertyDef } from '../../types/metamodel' +import type { PropertyDef } from '../../metamodel' export function validateProperties( raw: unknown, diff --git a/src/renderer/src/ai/tools/relationTools.ts b/packages/common/src/ai/tools/relationTools.ts similarity index 97% rename from src/renderer/src/ai/tools/relationTools.ts rename to packages/common/src/ai/tools/relationTools.ts index 2d7a2c2f..d5724684 100644 --- a/src/renderer/src/ai/tools/relationTools.ts +++ b/packages/common/src/ai/tools/relationTools.ts @@ -1,9 +1,8 @@ // ─── Relation tools: add_relation / update_relation / delete_relation ─────── -import type { C4Relation } from '../../types/c4' -import type { Metamodel } from '../../types/metamodel' -import type { ToolDef } from '../types' -import { fail, type ToolHandler } from './types' +import type { C4Relation } from '../../c4' +import type { Metamodel } from '../../metamodel' +import { fail, type ToolDef, type ToolHandler } from './types' import { validateProperties } from './propertyBag' export function buildRelationToolDefs(mm: Metamodel | undefined): ToolDef[] { diff --git a/src/renderer/src/ai/tools/types.ts b/packages/common/src/ai/tools/types.ts similarity index 77% rename from src/renderer/src/ai/tools/types.ts rename to packages/common/src/ai/tools/types.ts index 07cd72f2..61c86e5e 100644 --- a/src/renderer/src/ai/tools/types.ts +++ b/packages/common/src/ai/tools/types.ts @@ -2,6 +2,16 @@ import type { DiagramFacade } from '../diagramFacade' +/** A tool as offered to a model (an AI chat provider or an MCP client). */ +export interface ToolDef { + name: string + description: string + /** Conservative common-subset JSON Schema (type/properties/required/enum/ + * items/description/additionalProperties) — kept identical across every + * adapter so none has to translate a provider-specific schema dialect. */ + inputSchema: Record +} + export interface ToolRunContext { diagram: DiagramFacade /** tempId or real id -> real id (identity when not a known tempId). */ diff --git a/src/renderer/src/ai/tools/viewTools.ts b/packages/common/src/ai/tools/viewTools.ts similarity index 97% rename from src/renderer/src/ai/tools/viewTools.ts rename to packages/common/src/ai/tools/viewTools.ts index 31f7320d..39a89f74 100644 --- a/src/renderer/src/ai/tools/viewTools.ts +++ b/packages/common/src/ai/tools/viewTools.ts @@ -1,8 +1,7 @@ // ─── View tools: create_view / set_view_nodes / delete_view / set_active_view -import type { DiagramView } from '../../types/c4' -import type { ToolDef } from '../types' -import { fail, type ToolHandler } from './types' +import type { DiagramView } from '../../c4' +import { fail, type ToolDef, type ToolHandler } from './types' // 'dynamic' is deliberately excluded: it needs a sequenceId, and there is no // tool here to create one — exposing it would let the model build a broken view. diff --git a/src/renderer/src/types/c4.ts b/packages/common/src/c4.ts similarity index 98% rename from src/renderer/src/types/c4.ts rename to packages/common/src/c4.ts index 4975e219..64f43b52 100644 --- a/src/renderer/src/types/c4.ts +++ b/packages/common/src/c4.ts @@ -230,9 +230,9 @@ export interface DiagramData { * When absent, the built-in C4 preset is used. */ metamodel?: Metamodel /** Hub import template records — allows reconfiguring template values after import. */ - hubTemplates?: Record + hubTemplates?: Record /** Radical Hub catalogue metadata — present only in concept files published to the hub. */ - hub?: import('../hub/hubFormat').HubConceptMeta + hub?: import('./hubFormat').HubConceptMeta } // ─── React Flow data shapes ────────────────────────────────────────────────── diff --git a/src/renderer/src/export/exportGherkin.ts b/packages/common/src/formats/exportGherkin.ts similarity index 80% rename from src/renderer/src/export/exportGherkin.ts rename to packages/common/src/formats/exportGherkin.ts index 5050f7be..59a737f4 100644 --- a/src/renderer/src/export/exportGherkin.ts +++ b/packages/common/src/formats/exportGherkin.ts @@ -4,8 +4,8 @@ // `Feature:` per file). Scenarios with no `verifies` relation land in a // single "Ungrouped scenarios" file rather than being dropped silently. -import type { C4Node, C4Relation } from '../types/c4' -import { composeEarsSentence } from '../types/metamodel' +import type { C4Node, C4Relation } from '../c4' +import { composeEarsSentence } from '../metamodel' export interface GherkinFile { filename: string @@ -102,25 +102,3 @@ export function buildGherkinFiles( return files } - -/** Triggers a browser download for each file, staggered so Safari/Firefox - * don't drop downloads fired in the same tick (mirrors the pattern in - * documentStore.ts's defaultWebDownloader). */ -export function downloadGherkinFiles(files: GherkinFile[]): void { - files.forEach((file, i) => { - setTimeout(() => { - const blob = new Blob([file.content], { type: 'text/plain' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = file.filename - a.style.display = 'none' - document.body.appendChild(a) - a.click() - setTimeout(() => { - try { document.body.removeChild(a) } catch { /* already gone */ } - URL.revokeObjectURL(url) - }, 1000) - }, i * 250) - }) -} diff --git a/src/renderer/src/persist/mdFolder.ts b/packages/common/src/formats/mdFolder.ts similarity index 99% rename from src/renderer/src/persist/mdFolder.ts rename to packages/common/src/formats/mdFolder.ts index e5381a31..feb77a03 100644 --- a/src/renderer/src/persist/mdFolder.ts +++ b/packages/common/src/formats/mdFolder.ts @@ -25,9 +25,9 @@ import type { DiagramSnapshot, Presentation, NodePosition, -} from '../types/c4' -import { isContainerType } from '../types/c4' -import type { Metamodel } from '../types/metamodel' +} from '../c4' +import { isContainerType } from '../c4' +import type { Metamodel } from '../metamodel' /** File map: relative POSIX path → UTF-8 file content. */ export type FolderFiles = Record diff --git a/src/renderer/src/import/structurizrDsl.ts b/packages/common/src/formats/structurizrDsl.ts similarity index 99% rename from src/renderer/src/import/structurizrDsl.ts rename to packages/common/src/formats/structurizrDsl.ts index 0dc7063f..16eee34f 100644 --- a/src/renderer/src/import/structurizrDsl.ts +++ b/packages/common/src/formats/structurizrDsl.ts @@ -17,8 +17,8 @@ * All positions are set to (0,0) so the diagram auto-layouts on first open. */ -import type { C4Node, C4Relation, C4ElementType } from '../types/c4' -import { NODE_SIZES } from '../types/c4' +import type { C4Node, C4Relation, C4ElementType } from '../c4' +import { NODE_SIZES } from '../c4' // ─── Public result type ─────────────────────────────────────────────────────── diff --git a/src/renderer/src/hub/hubFormat.ts b/packages/common/src/hubFormat.ts similarity index 80% rename from src/renderer/src/hub/hubFormat.ts rename to packages/common/src/hubFormat.ts index 9b71625a..9e9b03da 100644 --- a/src/renderer/src/hub/hubFormat.ts +++ b/packages/common/src/hubFormat.ts @@ -3,9 +3,9 @@ // Every hub concept is a regular Radical Studio document (`DiagramData`: // nodes + relations, openable in the studio as-is) with one extra top-level // `hub` block carrying the catalogue metadata. The catalogue lives in the -// top-level hub//.radical (repo root, alongside website/ and -// docs/ — it's content, not app source) and is served verbatim; -// `hub/index.json` (generated at build time, see tools/hubCatalogue.ts) lists +// @radical/hub-catalogue package (catalogue//.radical) and is served +// verbatim under hub/; +// `hub/index.json` (generated at build time, see @radical/hub-catalogue) lists // lightweight summaries so the UI can browse without downloading every file. // // This module is imported by both the renderer and the node-side build @@ -75,6 +75,32 @@ export interface HubConceptSummary extends HubConceptMeta { preview: Record } +/** + * Persisted record of a hub concept import that used template parameters. + * Stored in the diagram so the user can reconfigure values later. + */ +export interface HubImportRecord { + conceptId: string + conceptName: string + templateParams: TemplateParam[] + /** Current substitution values (may change on reconfigure). */ + paramValues: Record + /** New node IDs (UUIDs) that were created by this import. */ + nodeIds: string[] + /** + * Original template nodes keyed by new node UUID. + * Contains the {{TOKEN}} placeholders before substitution. + * Positions (x/y/width/height) are excluded — they are user-managed. + */ + originalNodes: Record> + /** + * Per-node template params keyed by new node UUID. + * Derived from node-level templateParams at import time. + * Used by RightPanel to show only the params relevant to a given node. + */ + nodeParams?: Record +} + export const HUB_INDEX_FILE = 'index.json' const PREVIEW_KEYS = ['ears_type', 'priority', 'status', 'category', 'trigger'] as const diff --git a/src/renderer/src/types/metamodel/ears.ts b/packages/common/src/metamodel/ears.ts similarity index 100% rename from src/renderer/src/types/metamodel/ears.ts rename to packages/common/src/metamodel/ears.ts diff --git a/src/renderer/src/types/metamodel/index.ts b/packages/common/src/metamodel/index.ts similarity index 100% rename from src/renderer/src/types/metamodel/index.ts rename to packages/common/src/metamodel/index.ts diff --git a/src/renderer/src/types/metamodel/lookup.ts b/packages/common/src/metamodel/lookup.ts similarity index 100% rename from src/renderer/src/types/metamodel/lookup.ts rename to packages/common/src/metamodel/lookup.ts diff --git a/src/renderer/src/types/metamodel/presets/c4.ts b/packages/common/src/metamodel/presets/c4.ts similarity index 100% rename from src/renderer/src/types/metamodel/presets/c4.ts rename to packages/common/src/metamodel/presets/c4.ts diff --git a/src/renderer/src/types/metamodel/presets/ddd.ts b/packages/common/src/metamodel/presets/ddd.ts similarity index 100% rename from src/renderer/src/types/metamodel/presets/ddd.ts rename to packages/common/src/metamodel/presets/ddd.ts diff --git a/src/renderer/src/types/metamodel/presets/governance.ts b/packages/common/src/metamodel/presets/governance.ts similarity index 100% rename from src/renderer/src/types/metamodel/presets/governance.ts rename to packages/common/src/metamodel/presets/governance.ts diff --git a/src/renderer/src/types/metamodel/presets/index.ts b/packages/common/src/metamodel/presets/index.ts similarity index 100% rename from src/renderer/src/types/metamodel/presets/index.ts rename to packages/common/src/metamodel/presets/index.ts diff --git a/src/renderer/src/types/metamodel/types.ts b/packages/common/src/metamodel/types.ts similarity index 100% rename from src/renderer/src/types/metamodel/types.ts rename to packages/common/src/metamodel/types.ts diff --git a/src/renderer/src/types/metamodel/validate.ts b/packages/common/src/metamodel/validate.ts similarity index 100% rename from src/renderer/src/types/metamodel/validate.ts rename to packages/common/src/metamodel/validate.ts diff --git a/packages/common/src/model.ts b/packages/common/src/model.ts new file mode 100644 index 00000000..0126aaba --- /dev/null +++ b/packages/common/src/model.ts @@ -0,0 +1,194 @@ +// ─── Model operations ──────────────────────────────────────────────────────── +// +// The rules and edits behind every change to a model, independent of any UI. +// Studio's diagram store calls them inside its immer `set()` and adds its own +// effects (undo, notifications, canvas sync, live layout); createModelFacade +// uses them on a plain object, for tools that edit a model with no UI (the +// MCP server). +// +// check* functions return the user-facing reason a change is refused, or null. +// The mutating functions assume the matching check passed, and work on either +// a plain object or an immer draft. + +import type { C4Node, C4Relation, DiagramView } from './c4' +import { + builtInC4Metamodel, + builtInDddC4Metamodel, + builtInGovernanceMetamodel, + canAddMoreOfType, + inferRelationType, + isParentAllowed, + isRelationAllowed, + type Metamodel, +} from './metamodel' + +export interface ModelState { + c4Nodes: Record + c4Relations: Record + views: Record + activeViewId: string | null + metamodel?: Metamodel +} + +type NodeRecord = C4Node & Record + +/** The metamodel a loaded document runs under. Built-in metamodels are + * stored by id and replaced with the current preset; a document without + * one uses C4. */ +export function documentMetamodel(stored: Metamodel | undefined): Metamodel { + if (!stored) return builtInC4Metamodel() + if (stored.id === 'c4-builtin') return builtInC4Metamodel() + if (stored.id === 'c4-ddd-builtin') return builtInDddC4Metamodel() + if (stored.id === 'c4-ddd-governance-builtin') return builtInGovernanceMetamodel() + return stored +} + +/** Ids of every node below `id` in the containment tree. */ +export function descendantIds(id: string, nodes: Record): string[] { + const children = new Map() + for (const n of Object.values(nodes)) { + if (!n.parentId) continue + const list = children.get(n.parentId) + if (list) list.push(n.id) + else children.set(n.parentId, [n.id]) + } + const out: string[] = [] + const stack = [...(children.get(id) ?? [])] + while (stack.length) { + const next = stack.pop()! + out.push(next) + stack.push(...(children.get(next) ?? [])) + } + return out +} + +function typeLabel(mm: Metamodel | undefined, type: string): string { + return mm?.nodeTypes[type]?.label ?? type +} + +function placementError(mm: Metamodel | undefined, type: string, parent: C4Node | undefined): string { + const allowed = mm?.nodeTypes[type]?.allowedParents + const parentLabel = parent ? typeLabel(mm, parent.type) : 'the canvas root' + const allowedStr = allowed && allowed.length + ? allowed.map((t) => typeLabel(mm, t)).join(', ') + : 'the canvas root' + return `${parentLabel}. Allowed parents: ${allowedStr}.` +} + +/** Metamodel defaults (e.g. requirement ears_type='ubiquitous') become real + * fields on the node, so they persist instead of living only in UI fallbacks. */ +function materializeDefaults(node: NodeRecord, mm: Metamodel | undefined): void { + for (const p of mm?.nodeTypes[node.type]?.properties ?? []) { + if (p.default !== undefined && node[p.key] === undefined) node[p.key] = p.default + } +} + +// ── Nodes ──────────────────────────────────────────────────────────────────── + +export function checkAddNode(state: ModelState, node: Omit): string | null { + const mm = state.metamodel + const label = typeLabel(mm, node.type) + const count = Object.values(state.c4Nodes).filter((n) => n.type === node.type).length + if (!canAddMoreOfType(mm, node.type, count)) { + return `Cannot add another ${label}: maximum (${mm?.nodeTypes[node.type]?.cardinality?.max}) reached in the metamodel.` + } + const parent = node.parentId ? state.c4Nodes[node.parentId] : undefined + if (!isParentAllowed(mm, node.type, parent?.type)) { + return `Cannot place ${label} inside ${placementError(mm, node.type, parent)}` + } + return null +} + +/** Adds the node, and lists it in the active view when there is one. */ +export function insertNode(state: ModelState, id: string, node: Omit): void { + const created = { id, ...node } as NodeRecord + materializeDefaults(created, state.metamodel) + state.c4Nodes[id] = created + const view = state.activeViewId ? state.views[state.activeViewId] : undefined + if (view) view.nodeIds.push(id) +} + +export function checkNodeUpdate(state: ModelState, id: string, updates: Partial>): string | null { + const existing = state.c4Nodes[id] + if (!existing) return null + if (!('parentId' in updates) && !('type' in updates)) return null + const newType = (updates.type as string | undefined) ?? existing.type + const newParentId = ('parentId' in updates ? (updates.parentId as string | null | undefined) : existing.parentId) ?? undefined + const parent = newParentId ? state.c4Nodes[newParentId] : undefined + if (isParentAllowed(state.metamodel, newType, parent?.type)) return null + return `Cannot move ${typeLabel(state.metamodel, newType)} "${existing.label}" into ${placementError(state.metamodel, newType, parent)}` +} + +/** Applies the updates; a retyped node gets its new type's defaults. */ +export function patchNode(state: ModelState, id: string, updates: Partial>): void { + const node = state.c4Nodes[id] as NodeRecord | undefined + if (!node) return + Object.assign(node, updates) + if ('type' in updates) materializeDefaults(node, state.metamodel) +} + +/** Removes the node, its descendants, their relations and every view + * reference to them. Returns the removed node ids. */ +export function deleteNode(state: ModelState, id: string): Set { + const removed = new Set([id, ...descendantIds(id, state.c4Nodes)]) + for (const nid of removed) delete state.c4Nodes[nid] + for (const [rid, rel] of Object.entries(state.c4Relations)) { + if (removed.has(rel.sourceId) || removed.has(rel.targetId)) delete state.c4Relations[rid] + } + for (const view of Object.values(state.views)) { + view.nodeIds = view.nodeIds.filter((nid) => !removed.has(nid)) + if (view.collapsedNodeIds?.length) view.collapsedNodeIds = view.collapsedNodeIds.filter((nid) => !removed.has(nid)) + if (view.expandedNodeIds?.length) view.expandedNodeIds = view.expandedNodeIds.filter((nid) => !removed.has(nid)) + } + return removed +} + +// ── Relations ──────────────────────────────────────────────────────────────── + +export function checkAddRelation(state: ModelState, rel: Omit): string | null { + const src = state.c4Nodes[rel.sourceId] + const dst = state.c4Nodes[rel.targetId] + if (!src || !dst || isRelationAllowed(state.metamodel, src.type, dst.type)) return null + return `Relation not allowed: ${typeLabel(state.metamodel, src.type)} → ${typeLabel(state.metamodel, dst.type)}. The metamodel does not permit this connection.` +} + +/** Adds the relation, inferring its relationType from the metamodel when not given. */ +export function insertRelation(state: ModelState, id: string, rel: Omit): void { + const src = state.c4Nodes[rel.sourceId] + const dst = state.c4Nodes[rel.targetId] + const relationType = rel.relationType ?? (src && dst ? inferRelationType(state.metamodel, src.type, dst.type) : undefined) + state.c4Relations[id] = { id, ...rel, ...(relationType ? { relationType } : {}) } +} + +export function patchRelation(state: ModelState, id: string, updates: Partial>): void { + const rel = state.c4Relations[id] + if (rel) Object.assign(rel, updates) +} + +export function deleteRelation(state: ModelState, id: string): void { + delete state.c4Relations[id] +} + +// ── Views ──────────────────────────────────────────────────────────────────── + +export function insertView(state: ModelState, id: string, name: string): void { + state.views[id] = { id, name, nodeIds: [], positions: {} } +} + +/** Keeps known node ids only, deduplicated in order. False if there is no such view. */ +export function setViewNodeIds(state: ModelState, viewId: string, nodeIds: string[]): boolean { + const view = state.views[viewId] + if (!view) return false + const seen = new Set() + const ordered: string[] = [] + for (const id of nodeIds) { + if (id in state.c4Nodes && !seen.has(id)) { seen.add(id); ordered.push(id) } + } + view.nodeIds = ordered + return true +} + +export function setViewKind(state: ModelState, viewId: string, kind: DiagramView['kind']): void { + const view = state.views[viewId] + if (view) view.kind = kind +} diff --git a/packages/common/src/wireframe.ts b/packages/common/src/wireframe.ts new file mode 100644 index 00000000..921381f2 --- /dev/null +++ b/packages/common/src/wireframe.ts @@ -0,0 +1,37 @@ +// ─── Mockup wireframes ─────────────────────────────────────────────────────── +// A Mockup node may carry a low-fi SVG wireframe in its `wireframe` property. +// The SVG is only ever rendered through an data URI (see MockupNode), +// which never runs scripts or fetches external resources; sanitizeWireframeSvg +// still strips the obvious active content so the stored markup is inert even +// if something later inlines it. + +export const WIREFRAME_WIDTH = 400 +export const WIREFRAME_HEIGHT = 300 + +/** Upper bound on stored markup — keeps a model full of mockups from turning + * into megabytes of eagerly-loaded frontmatter. */ +export const MAX_WIREFRAME_CHARS = 20_000 + +/** Pulls the first … out of a model response and strips active + * content. Returns null when the response has no usable SVG. */ +export function sanitizeWireframeSvg(text: string): string | null { + const match = text.match(//i) + if (!match) return null + let svg = match[0] + svg = svg + .replace(//gi, '') + .replace(/]*\/>/gi, '') + .replace(//gi, '') + .replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '') + .replace(/\s(?:xlink:)?href\s*=\s*("(?!#)[^"]*"|'(?!#)[^']*')/gi, '') + if (!/\sxmlns=/.test(svg.slice(0, svg.indexOf('>')))) { + svg = svg.replace(/^ MAX_WIREFRAME_CHARS) return null + return svg +} + +/** Data URI for rendering a stored wireframe through . */ +export function wireframeDataUri(svg: string): string { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` +} diff --git a/tests/aiQueryLanguage.test.ts b/packages/common/tests/aiQueryLanguage.test.ts similarity index 97% rename from tests/aiQueryLanguage.test.ts rename to packages/common/tests/aiQueryLanguage.test.ts index 6c50477e..5bf28ac3 100644 --- a/tests/aiQueryLanguage.test.ts +++ b/packages/common/tests/aiQueryLanguage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { formatModelQueryResults, runModelQuery } from '../src/renderer/src/ai/queryLanguage' -import type { C4Node, C4Relation, DiagramView } from '../src/renderer/src/types/c4' +import { formatModelQueryResults, runModelQuery } from '../src/ai/queryLanguage' +import type { C4Node, C4Relation, DiagramView } from '../src/c4' const nodes: Record = { sys1: { diff --git a/tests/aiToolCatalogue.test.ts b/packages/common/tests/aiToolCatalogue.test.ts similarity index 96% rename from tests/aiToolCatalogue.test.ts rename to packages/common/tests/aiToolCatalogue.test.ts index cf9c4f55..c2cbd6d9 100644 --- a/tests/aiToolCatalogue.test.ts +++ b/packages/common/tests/aiToolCatalogue.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'vitest' -import { buildToolDefs, buildToolHandlers } from '../src/renderer/src/ai/tools' -import type { ToolRunContext } from '../src/renderer/src/ai/tools/types' -import type { DiagramFacade } from '../src/renderer/src/ai/diagramFacade' -import type { C4Node, C4Relation, DiagramView } from '../src/renderer/src/types/c4' -import type { Metamodel } from '../src/renderer/src/types/metamodel' +import { buildToolDefs, buildToolHandlers } from '../src/ai/tools/index' +import type { ToolRunContext } from '../src/ai/tools/types' +import type { DiagramFacade } from '../src/ai/diagramFacade' +import type { C4Node, C4Relation, DiagramView } from '../src/c4' +import type { Metamodel } from '../src/metamodel/index' function makeFacade(mm?: Metamodel): DiagramFacade & { _nodes: Record diff --git a/tests/earsSentence.test.ts b/packages/common/tests/earsSentence.test.ts similarity index 98% rename from tests/earsSentence.test.ts rename to packages/common/tests/earsSentence.test.ts index efe92129..ed558b7c 100644 --- a/tests/earsSentence.test.ts +++ b/packages/common/tests/earsSentence.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { composeEarsSentence, resolveEarsSubject, parseEarsSentence } from '../src/renderer/src/types/metamodel' +import { composeEarsSentence, resolveEarsSubject, parseEarsSentence } from '../src/metamodel/index' describe('composeEarsSentence', () => { it('ubiquitous with subject: " shall ."', () => { @@ -143,13 +143,13 @@ describe('parseEarsSentence', () => { it('roundtrips through composeEarsSentence back to an equivalent sentence', () => { const parsed = parseEarsSentence('When the user clicks save, the system shall persist the document.') - const { sentence } = composeEarsSentence(parsed) + const { sentence } = composeEarsSentence({ ...parsed }) expect(sentence).toBe('When the user clicks save, the system shall persist the document.') }) it('roundtrips an if+where complex sentence', () => { const parsed = parseEarsSentence('If the disk is full, where archiving is enabled, the system shall purge old logs.') - const { sentence } = composeEarsSentence(parsed) + const { sentence } = composeEarsSentence({ ...parsed }) expect(sentence).toBe('If the disk is full, where archiving is enabled, the system shall purge old logs.') }) }) diff --git a/tests/mdFolderPersistence.test.ts b/packages/common/tests/mdFolderPersistence.test.ts similarity index 98% rename from tests/mdFolderPersistence.test.ts rename to packages/common/tests/mdFolderPersistence.test.ts index 7d5e80eb..d1de823c 100644 --- a/tests/mdFolderPersistence.test.ts +++ b/packages/common/tests/mdFolderPersistence.test.ts @@ -4,8 +4,8 @@ import { deserializeFromMdFolder, isMdFolder, MD_MANIFEST_FILE, -} from '../src/renderer/src/persist/mdFolder' -import type { DiagramData, C4Node } from '../src/renderer/src/types/c4' +} from '../src/formats/mdFolder' +import type { DiagramData, C4Node } from '../src/c4' function node(partial: Partial & Pick): C4Node { return { diff --git a/packages/common/tests/modelFacade.test.ts b/packages/common/tests/modelFacade.test.ts new file mode 100644 index 00000000..76f6d4ae --- /dev/null +++ b/packages/common/tests/modelFacade.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest' +import { createModelFacade } from '../src/ai/modelFacade' +import type { C4Node, DiagramData } from '../src/c4' +import type { Metamodel, NodeTypeDef } from '../src/metamodel/index' + +const type = (id: string, extra: Partial = {}): NodeTypeDef => ({ + id, label: id[0].toUpperCase() + id.slice(1), color: '', fg: '', iconPath: '', width: 200, height: 100, ...extra, +}) + +/** Systems at the root (at most two), containers only inside systems, and + * "uses" relations between systems only. */ +const MM: Metamodel = { + id: 'test', + name: 'Test', + nodeTypes: { + system: type('system', { allowedAtRoot: true, cardinality: { max: 2 } }), + container: type('container', { allowedParents: ['system'] }), + requirement: type('requirement', { + allowedAtRoot: true, + properties: [{ key: 'priority', label: 'Priority', type: 'text', default: 'must' }], + }), + }, + relationTypes: { + uses: { id: 'uses', label: 'Uses', allowedPairs: [{ from: 'system', to: 'system' }] }, + }, +} + +const node = (t: string, label: string, extra: Partial = {}): Omit => + ({ type: t, label, collapsed: false, x: 0, y: 0, width: 200, height: 100, ...extra }) as Omit + +function facade(data: Partial = {}) { + let seq = 0 + return createModelFacade({ nodes: [], relations: [], metamodel: MM, ...data }, { newId: () => `id${++seq}` }) +} + +describe('createModelFacade', () => { + it('adds nodes with their metamodel defaults', () => { + const f = facade() + const id = f.addNode(node('requirement', 'Login')) + expect(id).toBe('id1') + expect(f.getNodes()[id]).toMatchObject({ id, label: 'Login', priority: 'must' }) + }) + + it('refuses placements the metamodel forbids, with the reason', () => { + const f = facade() + expect(f.addNode(node('container', 'API'))).toBe('') + expect(f.lastError).toBe('Cannot place Container inside the canvas root. Allowed parents: System.') + f.addNode(node('system', 'A')) + f.addNode(node('system', 'B')) + expect(f.addNode(node('system', 'C'))).toBe('') + expect(f.lastError).toBe('Cannot add another System: maximum (2) reached in the metamodel.') + expect(Object.keys(f.getNodes())).toHaveLength(2) + }) + + it('refuses a reparent that breaks containment and leaves the node unchanged', () => { + const f = facade() + const sys = f.addNode(node('system', 'A')) + const api = f.addNode(node('container', 'API', { parentId: sys })) + f.updateNode(api, { parentId: undefined }) + expect(f.lastError).toBe('Cannot move Container "API" into the canvas root. Allowed parents: System.') + expect(f.getNodes()[api].parentId).toBe(sys) + }) + + it('infers the relation type and refuses pairs the metamodel does not allow', () => { + const f = facade() + const a = f.addNode(node('system', 'A')) + const b = f.addNode(node('system', 'B')) + const api = f.addNode(node('container', 'API', { parentId: a })) + f.addRelation({ sourceId: a, targetId: b }) + expect(Object.values(f.getRelations())).toEqual([{ id: 'id4', sourceId: a, targetId: b, relationType: 'uses' }]) + f.addRelation({ sourceId: api, targetId: b }) + expect(f.lastError).toBe('Relation not allowed: Container → System. The metamodel does not permit this connection.') + expect(Object.keys(f.getRelations())).toHaveLength(1) + }) + + it('removing a node removes its descendants, their relations and view references', () => { + const f = facade() + const a = f.addNode(node('system', 'A')) + const b = f.addNode(node('system', 'B')) + const api = f.addNode(node('container', 'API', { parentId: a })) + f.addRelation({ sourceId: a, targetId: b }) + const view = f.addView!('Context') + f.setViewNodes!(view, [a, api, b]) + f.removeNode(a) + expect(Object.keys(f.getNodes())).toEqual([b]) + expect(f.getRelations()).toEqual({}) + expect(f.getViews!()[view].nodeIds).toEqual([b]) + }) + + it('keeps view node lists to known ids, deduplicated, and adds new nodes to the active view', () => { + const f = facade() + const a = f.addNode(node('system', 'A')) + const view = f.addView!('Context') + f.setViewNodes!(view, [a, 'ghost', a]) + expect(f.getViews!()[view].nodeIds).toEqual([a]) + f.setActiveView!(view) + const b = f.addNode(node('system', 'B')) + expect(f.getViews!()[view].nodeIds).toEqual([a, b]) + expect(f.getActiveView!()).toEqual({ id: view, name: 'Context', nodeIds: [a, b] }) + f.removeView!(view) + expect(f.getActiveView!()).toBeNull() + }) + + it('round-trips a document and carries the fields it does not edit', () => { + const input: DiagramData = { + nodes: [{ id: 's', type: 'system', label: 'S', collapsed: false, x: 1, y: 2, width: 3, height: 4 }], + relations: [], + sequences: [{ id: 'seq', name: 'Flow', steps: [] } as never], + metamodel: MM, + } + const f = createModelFacade(input) + f.updateNode('s', { label: 'S2' }) + const out = f.toDiagramData() + expect(out.nodes[0].label).toBe('S2') + expect(out.sequences).toEqual(input.sequences) + expect(out.metamodel).toEqual(MM) + expect(input.nodes[0].label).toBe('S') // the input is not mutated + }) + + it('clearDiagram keeps only the metamodel', () => { + const f = facade({ sequences: [{ id: 'seq', name: 'Flow', steps: [] } as never] }) + f.addNode(node('system', 'A')) + f.addView!('V') + f.clearDiagram!() + expect(f.toDiagramData()).toEqual({ nodes: [], relations: [], views: [], metamodel: MM }) + }) + + it('applies the C4 metamodel when the document has none', () => { + const f = createModelFacade({ nodes: [], relations: [] }) + expect(f.getMetamodel!()?.id).toBe('c4-builtin') + }) +}) diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json new file mode 100644 index 00000000..75880607 --- /dev/null +++ b/packages/common/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*", "tests/**/*"] +} diff --git a/packages/host-bridge/README.md b/packages/host-bridge/README.md new file mode 100644 index 00000000..7123c58b --- /dev/null +++ b/packages/host-bridge/README.md @@ -0,0 +1,21 @@ +# @radical/host-bridge + +The contract between Studio's renderer and the host it runs in. + +| Host | Implementation | +|---|---| +| Electron desktop app | `apps/studio/src/preload`: one IPC channel per capability | +| VS Code webview | `createVsCodeWebviewHost` in `./vscode`, backed by `apps/vscode` over postMessage | +| Browser | `WEB_HOST`: no native capabilities | + +- `@radical/host-bridge`: `HostBridge`, `HostCapabilities`, result types. Every + capability is optional; callers check for a method before using it. A failed + operation resolves with `success: false` and never throws. +- `@radical/host-bridge/vscode`: the webview ↔ extension message protocol + (`WebviewMessage`, `ExtensionMessage`), the webview-side host, and + `vscodeBootScript`, which the extension uses to pass the edited file to the page. + +Studio reaches its host only through `host()` in +`apps/studio/src/renderer/src/platform/host.ts`. + +No DOM or Node dependencies, so both the renderer and the extension can import it. diff --git a/packages/host-bridge/package.json b/packages/host-bridge/package.json new file mode 100644 index 00000000..b7331e06 --- /dev/null +++ b/packages/host-bridge/package.json @@ -0,0 +1,19 @@ +{ + "name": "@radical/host-bridge", + "version": "0.0.0", + "private": true, + "description": "The typed contract between Studio's renderer and its host (Electron, VS Code webview, browser), and the VS Code webview protocol.", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./vscode": "./src/vscode.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "typescript": "^5.3.3", + "vitest": "^4.1.4" + } +} diff --git a/packages/host-bridge/src/index.ts b/packages/host-bridge/src/index.ts new file mode 100644 index 00000000..1d944fc9 --- /dev/null +++ b/packages/host-bridge/src/index.ts @@ -0,0 +1,60 @@ +// ─── Host bridge ───────────────────────────────────────────────────────────── +// +// The contract between Studio's renderer and the host it runs in: the +// Electron desktop app, a VS Code webview, or a plain browser. The renderer +// only ever talks to a HostBridge; each host implements the part it supports: +// +// electron apps/studio/src/preload (IPC to the main process) +// vscode ./vscode — createVsCodeWebviewHost, backed by apps/vscode +// web no methods; the browser build uses localStorage and the File +// System Access API directly +// +// Every capability is optional. Callers check for a method before using it, +// and a failed operation resolves with `success: false` rather than throwing. + +export type HostKind = 'electron' | 'vscode' | 'web' + +export interface HostResult { + success: boolean + error?: string +} + +export interface ReadFileResult extends HostResult { + content?: string +} + +export interface ReadFolderResult extends HostResult { + files?: Record +} + +export interface FileChange { + filePath: string + content: string +} + +/** What a host can do. Everything but `kind` is optional. */ +export interface HostCapabilities { + /** Save dialog, then write `json` to the chosen file. */ + saveDiagram?(json: string): Promise + /** Open dialog, then read the chosen file. */ + openDiagram?(): Promise + readFile?(filePath: string): Promise + writeFile?(filePath: string, content: string): Promise + /** Folder dialog, then read every file in the chosen folder. */ + openFolder?(): Promise + /** Folder dialog only. */ + pickFolder?(): Promise + readFolder?(folderPath: string): Promise + writeFolder?(folderPath: string, files: Record): Promise + /** The file the host opened Studio with (CLI --file, VS Code editor), if any. */ + getWatchedPath?(): Promise + /** Called when the watched file changes outside Studio. */ + onFileChanged?(listener: (change: FileChange) => void): void +} + +export interface HostBridge extends HostCapabilities { + kind: HostKind +} + +/** The browser host: no native capabilities. */ +export const WEB_HOST: HostBridge = Object.freeze({ kind: 'web' as const }) diff --git a/packages/host-bridge/src/vscode.ts b/packages/host-bridge/src/vscode.ts new file mode 100644 index 00000000..c7d95b2d --- /dev/null +++ b/packages/host-bridge/src/vscode.ts @@ -0,0 +1,82 @@ +// ─── VS Code webview host ──────────────────────────────────────────────────── +// +// Studio runs inside a VS Code webview and reaches the file system through +// postMessage. Both sides of that protocol live here: the extension +// (apps/vscode) handles WebviewMessage and sends ExtensionMessage, and the +// webview side is createVsCodeWebviewHost, which Studio's renderer builds when +// it finds itself in a webview. +// +// The extension passes boot data (the file being edited) through a global set +// by an inline script; vscodeBootScript renders it. + +import type { FileChange, HostBridge } from './index' + +/** Webview → extension. */ +export type WebviewMessage = + | { type: 'readFile'; id: string; filePath: string } + | { type: 'writeFile'; filePath: string; content: string } + +/** Extension → webview. */ +export type ExtensionMessage = + | { type: 'readFile:response'; id: string; success: true; content: string } + | { type: 'readFile:response'; id: string; success: false; error: string } + | ({ type: 'file:external-change' } & FileChange) + +export interface VsCodeBoot { + /** The file the webview was opened for, or null for a blank editor. */ + filePath: string | null +} + +export const VSCODE_BOOT_GLOBAL = '__RADICAL_VSCODE__' + +/** Inline ` +} + +/** The subset of the object returned by acquireVsCodeApi() that we use. */ +export interface VsCodeApi { + postMessage(message: WebviewMessage): void +} + +/** Where extension messages arrive: the webview's window. */ +export interface MessageTarget { + addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void +} + +export function createVsCodeWebviewHost(api: VsCodeApi, boot: VsCodeBoot, target: MessageTarget): HostBridge { + const listeners: Array<(change: FileChange) => void> = [] + const pending = new Map void>() + let seq = 0 + + target.addEventListener('message', (event) => { + const msg = event.data as ExtensionMessage | null + if (!msg || typeof msg !== 'object' || !('type' in msg)) return + if (msg.type === 'file:external-change') { + for (const listener of listeners) listener({ filePath: msg.filePath, content: msg.content }) + } else if (msg.type === 'readFile:response') { + const resolve = pending.get(msg.id) + if (!resolve) return + pending.delete(msg.id) + resolve(msg.success ? { success: true, content: msg.content } : { success: false, error: msg.error || 'readFile failed' }) + } + }) + + return { + kind: 'vscode', + getWatchedPath: () => Promise.resolve(boot.filePath), + onFileChanged: (listener) => { listeners.push(listener) }, + readFile: (filePath) => new Promise((resolve) => { + const id = String(++seq) + pending.set(id, resolve) + api.postMessage({ type: 'readFile', id, filePath }) + }), + // The extension applies the write asynchronously and does not report back. + writeFile: (filePath, content) => { + api.postMessage({ type: 'writeFile', filePath, content }) + return Promise.resolve({ success: true }) + }, + } +} diff --git a/packages/host-bridge/tests/vscode.test.ts b/packages/host-bridge/tests/vscode.test.ts new file mode 100644 index 00000000..682cb22d --- /dev/null +++ b/packages/host-bridge/tests/vscode.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest' +import { createVsCodeWebviewHost, vscodeBootScript, type ExtensionMessage, type WebviewMessage } from '../src/vscode' + +function harness(filePath: string | null = '/work/model.radical') { + const sent: WebviewMessage[] = [] + let deliver: (event: { data: unknown }) => void = () => {} + const host = createVsCodeWebviewHost( + { postMessage: (m) => { sent.push(m) } }, + { filePath }, + { addEventListener: (_type, listener) => { deliver = listener } }, + ) + const reply = (msg: ExtensionMessage): void => deliver({ data: msg }) + return { host, sent, reply } +} + +describe('VS Code webview host', () => { + it('reports its kind and the file it was opened for', async () => { + const { host } = harness() + expect(host.kind).toBe('vscode') + expect(await host.getWatchedPath!()).toBe('/work/model.radical') + }) + + it('reads a file through a request / response pair', async () => { + const { host, sent, reply } = harness() + const pending = host.readFile!('/work/model.radical') + expect(sent).toEqual([{ type: 'readFile', id: '1', filePath: '/work/model.radical' }]) + reply({ type: 'readFile:response', id: '1', success: true, content: '{}' }) + expect(await pending).toEqual({ success: true, content: '{}' }) + }) + + it('resolves a failed read with success: false instead of throwing', async () => { + const { host, reply } = harness() + const pending = host.readFile!('/missing') + reply({ type: 'readFile:response', id: '1', success: false, error: 'ENOENT' }) + expect(await pending).toEqual({ success: false, error: 'ENOENT' }) + }) + + it('ignores responses for unknown requests and unrelated messages', async () => { + const { host, reply } = harness() + const pending = host.readFile!('/a') + reply({ type: 'readFile:response', id: '99', success: true, content: 'wrong' }) + ;(reply as (m: unknown) => void)({ unrelated: true }) + reply({ type: 'readFile:response', id: '1', success: true, content: 'right' }) + expect(await pending).toEqual({ success: true, content: 'right' }) + }) + + it('posts writes and resolves immediately', async () => { + const { host, sent } = harness() + expect(await host.writeFile!('/work/model.radical', '{"nodes":[]}')).toEqual({ success: true }) + expect(sent).toEqual([{ type: 'writeFile', filePath: '/work/model.radical', content: '{"nodes":[]}' }]) + }) + + it('forwards external changes to every listener', () => { + const { host, reply } = harness() + const seen: string[] = [] + host.onFileChanged!((c) => seen.push(`a:${c.content}`)) + host.onFileChanged!((c) => seen.push(`b:${c.content}`)) + reply({ type: 'file:external-change', filePath: '/work/model.radical', content: 'v2' }) + expect(seen).toEqual(['a:v2', 'b:v2']) + }) +}) + +describe('vscodeBootScript', () => { + it('sets the boot global and escapes "<" so a path cannot close the script tag', () => { + const html = vscodeBootScript({ filePath: '/x/.radical' }) + expect(html).toBe('') + }) +}) diff --git a/packages/host-bridge/tsconfig.json b/packages/host-bridge/tsconfig.json new file mode 100644 index 00000000..75880607 --- /dev/null +++ b/packages/host-bridge/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*", "tests/**/*"] +} diff --git a/packages/hub-catalogue/README.md b/packages/hub-catalogue/README.md new file mode 100644 index 00000000..d67fd28f --- /dev/null +++ b/packages/hub-catalogue/README.md @@ -0,0 +1,22 @@ +# @radical/hub-catalogue + +The Architecture Hub's concept catalogue and the tooling around it. + +- `catalogue//.radical`: one Radical Studio document per concept, + with a `hub` metadata block (format in `@radical/common/hubFormat`). +- `@radical/hub-catalogue`: `readCatalogue`, `validateCatalogue`, `buildIndex`, + `CATALOGUE_DIR`. +- `@radical/hub-catalogue/vite`: `hubCataloguePlugin()`. It serves the catalogue + under `/hub/` in dev and emits it at build time. + +Used by apps/hub (hub.radical.tools) and by apps/studio, which bundles the +catalogue so Hub import works offline. + +`./vite` is a small CommonJS file that loads `src/vite.ts` through tsx, so +Vite and electron-vite configs can import it like any other plugin: + +```ts +import { hubCataloguePlugin } from '@radical/hub-catalogue/vite' + +export default defineConfig({ plugins: [react(), hubCataloguePlugin()] }) +``` diff --git a/hub/adr/adr-api-versioning.radical b/packages/hub-catalogue/catalogue/adr/adr-api-versioning.radical similarity index 100% rename from hub/adr/adr-api-versioning.radical rename to packages/hub-catalogue/catalogue/adr/adr-api-versioning.radical diff --git a/hub/adr/adr-auth-strategy.radical b/packages/hub-catalogue/catalogue/adr/adr-auth-strategy.radical similarity index 100% rename from hub/adr/adr-auth-strategy.radical rename to packages/hub-catalogue/catalogue/adr/adr-auth-strategy.radical diff --git a/hub/adr/adr-caching-strategy.radical b/packages/hub-catalogue/catalogue/adr/adr-caching-strategy.radical similarity index 100% rename from hub/adr/adr-caching-strategy.radical rename to packages/hub-catalogue/catalogue/adr/adr-caching-strategy.radical diff --git a/hub/adr/adr-data-consistency-model.radical b/packages/hub-catalogue/catalogue/adr/adr-data-consistency-model.radical similarity index 100% rename from hub/adr/adr-data-consistency-model.radical rename to packages/hub-catalogue/catalogue/adr/adr-data-consistency-model.radical diff --git a/hub/adr/adr-data-store.radical b/packages/hub-catalogue/catalogue/adr/adr-data-store.radical similarity index 100% rename from hub/adr/adr-data-store.radical rename to packages/hub-catalogue/catalogue/adr/adr-data-store.radical diff --git a/hub/adr/adr-deployment-strategy.radical b/packages/hub-catalogue/catalogue/adr/adr-deployment-strategy.radical similarity index 100% rename from hub/adr/adr-deployment-strategy.radical rename to packages/hub-catalogue/catalogue/adr/adr-deployment-strategy.radical diff --git a/hub/adr/adr-event-schema-evolution.radical b/packages/hub-catalogue/catalogue/adr/adr-event-schema-evolution.radical similarity index 100% rename from hub/adr/adr-event-schema-evolution.radical rename to packages/hub-catalogue/catalogue/adr/adr-event-schema-evolution.radical diff --git a/hub/adr/adr-integration-style.radical b/packages/hub-catalogue/catalogue/adr/adr-integration-style.radical similarity index 100% rename from hub/adr/adr-integration-style.radical rename to packages/hub-catalogue/catalogue/adr/adr-integration-style.radical diff --git a/hub/adr/adr-ledger-reconciliation-strategy.radical b/packages/hub-catalogue/catalogue/adr/adr-ledger-reconciliation-strategy.radical similarity index 100% rename from hub/adr/adr-ledger-reconciliation-strategy.radical rename to packages/hub-catalogue/catalogue/adr/adr-ledger-reconciliation-strategy.radical diff --git a/hub/adr/adr-llm-provider-strategy.radical b/packages/hub-catalogue/catalogue/adr/adr-llm-provider-strategy.radical similarity index 100% rename from hub/adr/adr-llm-provider-strategy.radical rename to packages/hub-catalogue/catalogue/adr/adr-llm-provider-strategy.radical diff --git a/hub/adr/adr-marketplace-payments-model.radical b/packages/hub-catalogue/catalogue/adr/adr-marketplace-payments-model.radical similarity index 100% rename from hub/adr/adr-marketplace-payments-model.radical rename to packages/hub-catalogue/catalogue/adr/adr-marketplace-payments-model.radical diff --git a/hub/adr/adr-multi-region.radical b/packages/hub-catalogue/catalogue/adr/adr-multi-region.radical similarity index 100% rename from hub/adr/adr-multi-region.radical rename to packages/hub-catalogue/catalogue/adr/adr-multi-region.radical diff --git a/hub/adr/adr-multi-tenancy-strategy.radical b/packages/hub-catalogue/catalogue/adr/adr-multi-tenancy-strategy.radical similarity index 100% rename from hub/adr/adr-multi-tenancy-strategy.radical rename to packages/hub-catalogue/catalogue/adr/adr-multi-tenancy-strategy.radical diff --git a/hub/adr/adr-observability.radical b/packages/hub-catalogue/catalogue/adr/adr-observability.radical similarity index 100% rename from hub/adr/adr-observability.radical rename to packages/hub-catalogue/catalogue/adr/adr-observability.radical diff --git a/hub/adr/adr-realtime-transport.radical b/packages/hub-catalogue/catalogue/adr/adr-realtime-transport.radical similarity index 100% rename from hub/adr/adr-realtime-transport.radical rename to packages/hub-catalogue/catalogue/adr/adr-realtime-transport.radical diff --git a/hub/adr/adr-tech-stack.radical b/packages/hub-catalogue/catalogue/adr/adr-tech-stack.radical similarity index 100% rename from hub/adr/adr-tech-stack.radical rename to packages/hub-catalogue/catalogue/adr/adr-tech-stack.radical diff --git a/hub/adr/adr-zero-trust-network.radical b/packages/hub-catalogue/catalogue/adr/adr-zero-trust-network.radical similarity index 100% rename from hub/adr/adr-zero-trust-network.radical rename to packages/hub-catalogue/catalogue/adr/adr-zero-trust-network.radical diff --git a/hub/blueprint/bp-ai-assistant-platform.radical b/packages/hub-catalogue/catalogue/blueprint/bp-ai-assistant-platform.radical similarity index 100% rename from hub/blueprint/bp-ai-assistant-platform.radical rename to packages/hub-catalogue/catalogue/blueprint/bp-ai-assistant-platform.radical diff --git a/hub/blueprint/bp-analytical-data-platform.radical b/packages/hub-catalogue/catalogue/blueprint/bp-analytical-data-platform.radical similarity index 100% rename from hub/blueprint/bp-analytical-data-platform.radical rename to packages/hub-catalogue/catalogue/blueprint/bp-analytical-data-platform.radical diff --git a/hub/blueprint/bp-ecommerce-platform.radical b/packages/hub-catalogue/catalogue/blueprint/bp-ecommerce-platform.radical similarity index 100% rename from hub/blueprint/bp-ecommerce-platform.radical rename to packages/hub-catalogue/catalogue/blueprint/bp-ecommerce-platform.radical diff --git a/hub/blueprint/bp-fintech-ledger-platform.radical b/packages/hub-catalogue/catalogue/blueprint/bp-fintech-ledger-platform.radical similarity index 100% rename from hub/blueprint/bp-fintech-ledger-platform.radical rename to packages/hub-catalogue/catalogue/blueprint/bp-fintech-ledger-platform.radical diff --git a/hub/blueprint/bp-marketplace-platform.radical b/packages/hub-catalogue/catalogue/blueprint/bp-marketplace-platform.radical similarity index 100% rename from hub/blueprint/bp-marketplace-platform.radical rename to packages/hub-catalogue/catalogue/blueprint/bp-marketplace-platform.radical diff --git a/hub/blueprint/bp-realtime-collaboration.radical b/packages/hub-catalogue/catalogue/blueprint/bp-realtime-collaboration.radical similarity index 100% rename from hub/blueprint/bp-realtime-collaboration.radical rename to packages/hub-catalogue/catalogue/blueprint/bp-realtime-collaboration.radical diff --git a/hub/blueprint/bp-saas-b2b-platform.radical b/packages/hub-catalogue/catalogue/blueprint/bp-saas-b2b-platform.radical similarity index 100% rename from hub/blueprint/bp-saas-b2b-platform.radical rename to packages/hub-catalogue/catalogue/blueprint/bp-saas-b2b-platform.radical diff --git a/hub/fitness-function/ff-accessibility-scan.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-accessibility-scan.radical similarity index 100% rename from hub/fitness-function/ff-accessibility-scan.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-accessibility-scan.radical diff --git a/hub/fitness-function/ff-ai-eval-gate.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-ai-eval-gate.radical similarity index 100% rename from hub/fitness-function/ff-ai-eval-gate.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-ai-eval-gate.radical diff --git a/hub/fitness-function/ff-api-contract.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-api-contract.radical similarity index 100% rename from hub/fitness-function/ff-api-contract.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-api-contract.radical diff --git a/hub/fitness-function/ff-capacity-load-test.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-capacity-load-test.radical similarity index 100% rename from hub/fitness-function/ff-capacity-load-test.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-capacity-load-test.radical diff --git a/hub/fitness-function/ff-carbon-footprint-guard.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-carbon-footprint-guard.radical similarity index 100% rename from hub/fitness-function/ff-carbon-footprint-guard.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-carbon-footprint-guard.radical diff --git a/hub/fitness-function/ff-change-failure-rate.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-change-failure-rate.radical similarity index 100% rename from hub/fitness-function/ff-change-failure-rate.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-change-failure-rate.radical diff --git a/hub/fitness-function/ff-chaos-resilience-test.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-chaos-resilience-test.radical similarity index 100% rename from hub/fitness-function/ff-chaos-resilience-test.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-chaos-resilience-test.radical diff --git a/hub/fitness-function/ff-cost-efficiency-guard.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-cost-efficiency-guard.radical similarity index 100% rename from hub/fitness-function/ff-cost-efficiency-guard.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-cost-efficiency-guard.radical diff --git a/hub/fitness-function/ff-coupling-guard.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-coupling-guard.radical similarity index 100% rename from hub/fitness-function/ff-coupling-guard.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-coupling-guard.radical diff --git a/hub/fitness-function/ff-data-integrity-check.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-data-integrity-check.radical similarity index 100% rename from hub/fitness-function/ff-data-integrity-check.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-data-integrity-check.radical diff --git a/hub/fitness-function/ff-data-residency.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-data-residency.radical similarity index 100% rename from hub/fitness-function/ff-data-residency.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-data-residency.radical diff --git a/hub/fitness-function/ff-dependency-rules.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-dependency-rules.radical similarity index 100% rename from hub/fitness-function/ff-dependency-rules.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-dependency-rules.radical diff --git a/hub/fitness-function/ff-deploy-frequency.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-deploy-frequency.radical similarity index 100% rename from hub/fitness-function/ff-deploy-frequency.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-deploy-frequency.radical diff --git a/hub/fitness-function/ff-dr-drill-success.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-dr-drill-success.radical similarity index 100% rename from hub/fitness-function/ff-dr-drill-success.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-dr-drill-success.radical diff --git a/hub/fitness-function/ff-duplication-guard.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-duplication-guard.radical similarity index 100% rename from hub/fitness-function/ff-duplication-guard.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-duplication-guard.radical diff --git a/hub/fitness-function/ff-error-budget.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-error-budget.radical similarity index 100% rename from hub/fitness-function/ff-error-budget.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-error-budget.radical diff --git a/hub/fitness-function/ff-fail-safe-verification.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-fail-safe-verification.radical similarity index 100% rename from hub/fitness-function/ff-fail-safe-verification.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-fail-safe-verification.radical diff --git a/hub/fitness-function/ff-infra-drift.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-infra-drift.radical similarity index 100% rename from hub/fitness-function/ff-infra-drift.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-infra-drift.radical diff --git a/hub/fitness-function/ff-latency-slo.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-latency-slo.radical similarity index 100% rename from hub/fitness-function/ff-latency-slo.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-latency-slo.radical diff --git a/hub/fitness-function/ff-lead-time.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-lead-time.radical similarity index 100% rename from hub/fitness-function/ff-lead-time.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-lead-time.radical diff --git a/hub/fitness-function/ff-ledger-balance-integrity.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-ledger-balance-integrity.radical similarity index 100% rename from hub/fitness-function/ff-ledger-balance-integrity.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-ledger-balance-integrity.radical diff --git a/hub/fitness-function/ff-license-compliance.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-license-compliance.radical similarity index 100% rename from hub/fitness-function/ff-license-compliance.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-license-compliance.radical diff --git a/hub/fitness-function/ff-mttr.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-mttr.radical similarity index 100% rename from hub/fitness-function/ff-mttr.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-mttr.radical diff --git a/hub/fitness-function/ff-noisy-neighbor-guard.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-noisy-neighbor-guard.radical similarity index 100% rename from hub/fitness-function/ff-noisy-neighbor-guard.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-noisy-neighbor-guard.radical diff --git a/hub/fitness-function/ff-sast-scan.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-sast-scan.radical similarity index 100% rename from hub/fitness-function/ff-sast-scan.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-sast-scan.radical diff --git a/hub/fitness-function/ff-scale-out-verification.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-scale-out-verification.radical similarity index 100% rename from hub/fitness-function/ff-scale-out-verification.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-scale-out-verification.radical diff --git a/hub/fitness-function/ff-security-scan.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-security-scan.radical similarity index 100% rename from hub/fitness-function/ff-security-scan.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-security-scan.radical diff --git a/hub/fitness-function/ff-startup-shutdown-check.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-startup-shutdown-check.radical similarity index 100% rename from hub/fitness-function/ff-startup-shutdown-check.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-startup-shutdown-check.radical diff --git a/hub/fitness-function/ff-sync-convergence-test.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-sync-convergence-test.radical similarity index 100% rename from hub/fitness-function/ff-sync-convergence-test.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-sync-convergence-test.radical diff --git a/hub/fitness-function/ff-test-coverage.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-test-coverage.radical similarity index 100% rename from hub/fitness-function/ff-test-coverage.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-test-coverage.radical diff --git a/hub/fitness-function/ff-web-vitals.radical b/packages/hub-catalogue/catalogue/fitness-function/ff-web-vitals.radical similarity index 100% rename from hub/fitness-function/ff-web-vitals.radical rename to packages/hub-catalogue/catalogue/fitness-function/ff-web-vitals.radical diff --git a/hub/pattern/pattern-anti-corruption-layer.radical b/packages/hub-catalogue/catalogue/pattern/pattern-anti-corruption-layer.radical similarity index 100% rename from hub/pattern/pattern-anti-corruption-layer.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-anti-corruption-layer.radical diff --git a/hub/pattern/pattern-api-gateway.radical b/packages/hub-catalogue/catalogue/pattern/pattern-api-gateway.radical similarity index 100% rename from hub/pattern/pattern-api-gateway.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-api-gateway.radical diff --git a/hub/pattern/pattern-bff.radical b/packages/hub-catalogue/catalogue/pattern/pattern-bff.radical similarity index 100% rename from hub/pattern/pattern-bff.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-bff.radical diff --git a/hub/pattern/pattern-bulkhead.radical b/packages/hub-catalogue/catalogue/pattern/pattern-bulkhead.radical similarity index 100% rename from hub/pattern/pattern-bulkhead.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-bulkhead.radical diff --git a/hub/pattern/pattern-circuit-breaker.radical b/packages/hub-catalogue/catalogue/pattern/pattern-circuit-breaker.radical similarity index 100% rename from hub/pattern/pattern-circuit-breaker.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-circuit-breaker.radical diff --git a/hub/pattern/pattern-cqrs.radical b/packages/hub-catalogue/catalogue/pattern/pattern-cqrs.radical similarity index 100% rename from hub/pattern/pattern-cqrs.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-cqrs.radical diff --git a/hub/pattern/pattern-crdt-sync.radical b/packages/hub-catalogue/catalogue/pattern/pattern-crdt-sync.radical similarity index 100% rename from hub/pattern/pattern-crdt-sync.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-crdt-sync.radical diff --git a/hub/pattern/pattern-event-driven.radical b/packages/hub-catalogue/catalogue/pattern/pattern-event-driven.radical similarity index 100% rename from hub/pattern/pattern-event-driven.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-event-driven.radical diff --git a/hub/pattern/pattern-event-sourced-ledger.radical b/packages/hub-catalogue/catalogue/pattern/pattern-event-sourced-ledger.radical similarity index 100% rename from hub/pattern/pattern-event-sourced-ledger.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-event-sourced-ledger.radical diff --git a/hub/pattern/pattern-hexagonal.radical b/packages/hub-catalogue/catalogue/pattern/pattern-hexagonal.radical similarity index 100% rename from hub/pattern/pattern-hexagonal.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-hexagonal.radical diff --git a/hub/pattern/pattern-microservices.radical b/packages/hub-catalogue/catalogue/pattern/pattern-microservices.radical similarity index 100% rename from hub/pattern/pattern-microservices.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-microservices.radical diff --git a/hub/pattern/pattern-rag-pipeline.radical b/packages/hub-catalogue/catalogue/pattern/pattern-rag-pipeline.radical similarity index 100% rename from hub/pattern/pattern-rag-pipeline.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-rag-pipeline.radical diff --git a/hub/pattern/pattern-rate-limiter.radical b/packages/hub-catalogue/catalogue/pattern/pattern-rate-limiter.radical similarity index 100% rename from hub/pattern/pattern-rate-limiter.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-rate-limiter.radical diff --git a/hub/pattern/pattern-saga.radical b/packages/hub-catalogue/catalogue/pattern/pattern-saga.radical similarity index 100% rename from hub/pattern/pattern-saga.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-saga.radical diff --git a/hub/pattern/pattern-sidecar.radical b/packages/hub-catalogue/catalogue/pattern/pattern-sidecar.radical similarity index 100% rename from hub/pattern/pattern-sidecar.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-sidecar.radical diff --git a/hub/pattern/pattern-strangler-fig.radical b/packages/hub-catalogue/catalogue/pattern/pattern-strangler-fig.radical similarity index 100% rename from hub/pattern/pattern-strangler-fig.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-strangler-fig.radical diff --git a/hub/pattern/pattern-transactional-outbox.radical b/packages/hub-catalogue/catalogue/pattern/pattern-transactional-outbox.radical similarity index 100% rename from hub/pattern/pattern-transactional-outbox.radical rename to packages/hub-catalogue/catalogue/pattern/pattern-transactional-outbox.radical diff --git a/hub/requirement/req-accessibility.radical b/packages/hub-catalogue/catalogue/requirement/req-accessibility.radical similarity index 100% rename from hub/requirement/req-accessibility.radical rename to packages/hub-catalogue/catalogue/requirement/req-accessibility.radical diff --git a/hub/requirement/req-ai-autonomous-resolution.radical b/packages/hub-catalogue/catalogue/requirement/req-ai-autonomous-resolution.radical similarity index 100% rename from hub/requirement/req-ai-autonomous-resolution.radical rename to packages/hub-catalogue/catalogue/requirement/req-ai-autonomous-resolution.radical diff --git a/hub/requirement/req-ai-groundedness.radical b/packages/hub-catalogue/catalogue/requirement/req-ai-groundedness.radical similarity index 100% rename from hub/requirement/req-ai-groundedness.radical rename to packages/hub-catalogue/catalogue/requirement/req-ai-groundedness.radical diff --git a/hub/requirement/req-ai-human-handoff.radical b/packages/hub-catalogue/catalogue/requirement/req-ai-human-handoff.radical similarity index 100% rename from hub/requirement/req-ai-human-handoff.radical rename to packages/hub-catalogue/catalogue/requirement/req-ai-human-handoff.radical diff --git a/hub/requirement/req-alerting.radical b/packages/hub-catalogue/catalogue/requirement/req-alerting.radical similarity index 100% rename from hub/requirement/req-alerting.radical rename to packages/hub-catalogue/catalogue/requirement/req-alerting.radical diff --git a/hub/requirement/req-api-response-time.radical b/packages/hub-catalogue/catalogue/requirement/req-api-response-time.radical similarity index 100% rename from hub/requirement/req-api-response-time.radical rename to packages/hub-catalogue/catalogue/requirement/req-api-response-time.radical diff --git a/hub/requirement/req-api-standard.radical b/packages/hub-catalogue/catalogue/requirement/req-api-standard.radical similarity index 100% rename from hub/requirement/req-api-standard.radical rename to packages/hub-catalogue/catalogue/requirement/req-api-standard.radical diff --git a/hub/requirement/req-audit-logging.radical b/packages/hub-catalogue/catalogue/requirement/req-audit-logging.radical similarity index 100% rename from hub/requirement/req-audit-logging.radical rename to packages/hub-catalogue/catalogue/requirement/req-audit-logging.radical diff --git a/hub/requirement/req-auth-response-time.radical b/packages/hub-catalogue/catalogue/requirement/req-auth-response-time.radical similarity index 100% rename from hub/requirement/req-auth-response-time.radical rename to packages/hub-catalogue/catalogue/requirement/req-auth-response-time.radical diff --git a/hub/requirement/req-auth.radical b/packages/hub-catalogue/catalogue/requirement/req-auth.radical similarity index 100% rename from hub/requirement/req-auth.radical rename to packages/hub-catalogue/catalogue/requirement/req-auth.radical diff --git a/hub/requirement/req-autoscaling.radical b/packages/hub-catalogue/catalogue/requirement/req-autoscaling.radical similarity index 100% rename from hub/requirement/req-autoscaling.radical rename to packages/hub-catalogue/catalogue/requirement/req-autoscaling.radical diff --git a/hub/requirement/req-availability.radical b/packages/hub-catalogue/catalogue/requirement/req-availability.radical similarity index 100% rename from hub/requirement/req-availability.radical rename to packages/hub-catalogue/catalogue/requirement/req-availability.radical diff --git a/hub/requirement/req-backward-compatibility.radical b/packages/hub-catalogue/catalogue/requirement/req-backward-compatibility.radical similarity index 100% rename from hub/requirement/req-backward-compatibility.radical rename to packages/hub-catalogue/catalogue/requirement/req-backward-compatibility.radical diff --git a/hub/requirement/req-batch-job-completion.radical b/packages/hub-catalogue/catalogue/requirement/req-batch-job-completion.radical similarity index 100% rename from hub/requirement/req-batch-job-completion.radical rename to packages/hub-catalogue/catalogue/requirement/req-batch-job-completion.radical diff --git a/hub/requirement/req-carbon-efficiency.radical b/packages/hub-catalogue/catalogue/requirement/req-carbon-efficiency.radical similarity index 100% rename from hub/requirement/req-carbon-efficiency.radical rename to packages/hub-catalogue/catalogue/requirement/req-carbon-efficiency.radical diff --git a/hub/requirement/req-centralised-logging.radical b/packages/hub-catalogue/catalogue/requirement/req-centralised-logging.radical similarity index 100% rename from hub/requirement/req-centralised-logging.radical rename to packages/hub-catalogue/catalogue/requirement/req-centralised-logging.radical diff --git a/hub/requirement/req-ci-pipeline-time.radical b/packages/hub-catalogue/catalogue/requirement/req-ci-pipeline-time.radical similarity index 100% rename from hub/requirement/req-ci-pipeline-time.radical rename to packages/hub-catalogue/catalogue/requirement/req-ci-pipeline-time.radical diff --git a/hub/requirement/req-code-complexity.radical b/packages/hub-catalogue/catalogue/requirement/req-code-complexity.radical similarity index 100% rename from hub/requirement/req-code-complexity.radical rename to packages/hub-catalogue/catalogue/requirement/req-code-complexity.radical diff --git a/hub/requirement/req-config-propagation.radical b/packages/hub-catalogue/catalogue/requirement/req-config-propagation.radical similarity index 100% rename from hub/requirement/req-config-propagation.radical rename to packages/hub-catalogue/catalogue/requirement/req-config-propagation.radical diff --git a/hub/requirement/req-data-integrity.radical b/packages/hub-catalogue/catalogue/requirement/req-data-integrity.radical similarity index 100% rename from hub/requirement/req-data-integrity.radical rename to packages/hub-catalogue/catalogue/requirement/req-data-integrity.radical diff --git a/hub/requirement/req-data-retention.radical b/packages/hub-catalogue/catalogue/requirement/req-data-retention.radical similarity index 100% rename from hub/requirement/req-data-retention.radical rename to packages/hub-catalogue/catalogue/requirement/req-data-retention.radical diff --git a/hub/requirement/req-db-query-response.radical b/packages/hub-catalogue/catalogue/requirement/req-db-query-response.radical similarity index 100% rename from hub/requirement/req-db-query-response.radical rename to packages/hub-catalogue/catalogue/requirement/req-db-query-response.radical diff --git a/hub/requirement/req-disaster-recovery.radical b/packages/hub-catalogue/catalogue/requirement/req-disaster-recovery.radical similarity index 100% rename from hub/requirement/req-disaster-recovery.radical rename to packages/hub-catalogue/catalogue/requirement/req-disaster-recovery.radical diff --git a/hub/requirement/req-distributed-tracing.radical b/packages/hub-catalogue/catalogue/requirement/req-distributed-tracing.radical similarity index 100% rename from hub/requirement/req-distributed-tracing.radical rename to packages/hub-catalogue/catalogue/requirement/req-distributed-tracing.radical diff --git a/hub/requirement/req-encryption.radical b/packages/hub-catalogue/catalogue/requirement/req-encryption.radical similarity index 100% rename from hub/requirement/req-encryption.radical rename to packages/hub-catalogue/catalogue/requirement/req-encryption.radical diff --git a/hub/requirement/req-error-rate.radical b/packages/hub-catalogue/catalogue/requirement/req-error-rate.radical similarity index 100% rename from hub/requirement/req-error-rate.radical rename to packages/hub-catalogue/catalogue/requirement/req-error-rate.radical diff --git a/hub/requirement/req-error-recovery.radical b/packages/hub-catalogue/catalogue/requirement/req-error-recovery.radical similarity index 100% rename from hub/requirement/req-error-recovery.radical rename to packages/hub-catalogue/catalogue/requirement/req-error-recovery.radical diff --git a/hub/requirement/req-event-schema-compatibility.radical b/packages/hub-catalogue/catalogue/requirement/req-event-schema-compatibility.radical similarity index 100% rename from hub/requirement/req-event-schema-compatibility.radical rename to packages/hub-catalogue/catalogue/requirement/req-event-schema-compatibility.radical diff --git a/hub/requirement/req-fail-safe-default.radical b/packages/hub-catalogue/catalogue/requirement/req-fail-safe-default.radical similarity index 100% rename from hub/requirement/req-fail-safe-default.radical rename to packages/hub-catalogue/catalogue/requirement/req-fail-safe-default.radical diff --git a/hub/requirement/req-fast-startup-graceful-shutdown.radical b/packages/hub-catalogue/catalogue/requirement/req-fast-startup-graceful-shutdown.radical similarity index 100% rename from hub/requirement/req-fast-startup-graceful-shutdown.radical rename to packages/hub-catalogue/catalogue/requirement/req-fast-startup-graceful-shutdown.radical diff --git a/hub/requirement/req-gdpr-erasure.radical b/packages/hub-catalogue/catalogue/requirement/req-gdpr-erasure.radical similarity index 100% rename from hub/requirement/req-gdpr-erasure.radical rename to packages/hub-catalogue/catalogue/requirement/req-gdpr-erasure.radical diff --git a/hub/requirement/req-graceful-degradation.radical b/packages/hub-catalogue/catalogue/requirement/req-graceful-degradation.radical similarity index 100% rename from hub/requirement/req-graceful-degradation.radical rename to packages/hub-catalogue/catalogue/requirement/req-graceful-degradation.radical diff --git a/hub/requirement/req-health-checks.radical b/packages/hub-catalogue/catalogue/requirement/req-health-checks.radical similarity index 100% rename from hub/requirement/req-health-checks.radical rename to packages/hub-catalogue/catalogue/requirement/req-health-checks.radical diff --git a/hub/requirement/req-iac.radical b/packages/hub-catalogue/catalogue/requirement/req-iac.radical similarity index 100% rename from hub/requirement/req-iac.radical rename to packages/hub-catalogue/catalogue/requirement/req-iac.radical diff --git a/hub/requirement/req-idempotency.radical b/packages/hub-catalogue/catalogue/requirement/req-idempotency.radical similarity index 100% rename from hub/requirement/req-idempotency.radical rename to packages/hub-catalogue/catalogue/requirement/req-idempotency.radical diff --git a/hub/requirement/req-input-validation.radical b/packages/hub-catalogue/catalogue/requirement/req-input-validation.radical similarity index 100% rename from hub/requirement/req-input-validation.radical rename to packages/hub-catalogue/catalogue/requirement/req-input-validation.radical diff --git a/hub/requirement/req-least-privilege.radical b/packages/hub-catalogue/catalogue/requirement/req-least-privilege.radical similarity index 100% rename from hub/requirement/req-least-privilege.radical rename to packages/hub-catalogue/catalogue/requirement/req-least-privilege.radical diff --git a/hub/requirement/req-ledger-immutability.radical b/packages/hub-catalogue/catalogue/requirement/req-ledger-immutability.radical similarity index 100% rename from hub/requirement/req-ledger-immutability.radical rename to packages/hub-catalogue/catalogue/requirement/req-ledger-immutability.radical diff --git a/hub/requirement/req-mfa.radical b/packages/hub-catalogue/catalogue/requirement/req-mfa.radical similarity index 100% rename from hub/requirement/req-mfa.radical rename to packages/hub-catalogue/catalogue/requirement/req-mfa.radical diff --git a/hub/requirement/req-ml-extraction-accuracy.radical b/packages/hub-catalogue/catalogue/requirement/req-ml-extraction-accuracy.radical similarity index 100% rename from hub/requirement/req-ml-extraction-accuracy.radical rename to packages/hub-catalogue/catalogue/requirement/req-ml-extraction-accuracy.radical diff --git a/hub/requirement/req-onboarding-time.radical b/packages/hub-catalogue/catalogue/requirement/req-onboarding-time.radical similarity index 100% rename from hub/requirement/req-onboarding-time.radical rename to packages/hub-catalogue/catalogue/requirement/req-onboarding-time.radical diff --git a/hub/requirement/req-page-load-time.radical b/packages/hub-catalogue/catalogue/requirement/req-page-load-time.radical similarity index 100% rename from hub/requirement/req-page-load-time.radical rename to packages/hub-catalogue/catalogue/requirement/req-page-load-time.radical diff --git a/hub/requirement/req-payout-latency.radical b/packages/hub-catalogue/catalogue/requirement/req-payout-latency.radical similarity index 100% rename from hub/requirement/req-payout-latency.radical rename to packages/hub-catalogue/catalogue/requirement/req-payout-latency.radical diff --git a/hub/requirement/req-presence-latency.radical b/packages/hub-catalogue/catalogue/requirement/req-presence-latency.radical similarity index 100% rename from hub/requirement/req-presence-latency.radical rename to packages/hub-catalogue/catalogue/requirement/req-presence-latency.radical diff --git a/hub/requirement/req-rate-limiting.radical b/packages/hub-catalogue/catalogue/requirement/req-rate-limiting.radical similarity index 100% rename from hub/requirement/req-rate-limiting.radical rename to packages/hub-catalogue/catalogue/requirement/req-rate-limiting.radical diff --git a/hub/requirement/req-report-generation.radical b/packages/hub-catalogue/catalogue/requirement/req-report-generation.radical similarity index 100% rename from hub/requirement/req-report-generation.radical rename to packages/hub-catalogue/catalogue/requirement/req-report-generation.radical diff --git a/hub/requirement/req-resource-efficiency.radical b/packages/hub-catalogue/catalogue/requirement/req-resource-efficiency.radical similarity index 100% rename from hub/requirement/req-resource-efficiency.radical rename to packages/hub-catalogue/catalogue/requirement/req-resource-efficiency.radical diff --git a/hub/requirement/req-resource-headroom.radical b/packages/hub-catalogue/catalogue/requirement/req-resource-headroom.radical similarity index 100% rename from hub/requirement/req-resource-headroom.radical rename to packages/hub-catalogue/catalogue/requirement/req-resource-headroom.radical diff --git a/hub/requirement/req-resource-isolation.radical b/packages/hub-catalogue/catalogue/requirement/req-resource-isolation.radical similarity index 100% rename from hub/requirement/req-resource-isolation.radical rename to packages/hub-catalogue/catalogue/requirement/req-resource-isolation.radical diff --git a/hub/requirement/req-search-response-time.radical b/packages/hub-catalogue/catalogue/requirement/req-search-response-time.radical similarity index 100% rename from hub/requirement/req-search-response-time.radical rename to packages/hub-catalogue/catalogue/requirement/req-search-response-time.radical diff --git a/hub/requirement/req-secrets-management.radical b/packages/hub-catalogue/catalogue/requirement/req-secrets-management.radical similarity index 100% rename from hub/requirement/req-secrets-management.radical rename to packages/hub-catalogue/catalogue/requirement/req-secrets-management.radical diff --git a/hub/requirement/req-task-success-rate.radical b/packages/hub-catalogue/catalogue/requirement/req-task-success-rate.radical similarity index 100% rename from hub/requirement/req-task-success-rate.radical rename to packages/hub-catalogue/catalogue/requirement/req-task-success-rate.radical diff --git a/hub/requirement/req-test-coverage.radical b/packages/hub-catalogue/catalogue/requirement/req-test-coverage.radical similarity index 100% rename from hub/requirement/req-test-coverage.radical rename to packages/hub-catalogue/catalogue/requirement/req-test-coverage.radical diff --git a/hub/requirement/req-throughput.radical b/packages/hub-catalogue/catalogue/requirement/req-throughput.radical similarity index 100% rename from hub/requirement/req-throughput.radical rename to packages/hub-catalogue/catalogue/requirement/req-throughput.radical diff --git a/hub/requirement/req-vulnerability-scanning.radical b/packages/hub-catalogue/catalogue/requirement/req-vulnerability-scanning.radical similarity index 100% rename from hub/requirement/req-vulnerability-scanning.radical rename to packages/hub-catalogue/catalogue/requirement/req-vulnerability-scanning.radical diff --git a/hub/requirement/req-zero-downtime-deploy.radical b/packages/hub-catalogue/catalogue/requirement/req-zero-downtime-deploy.radical similarity index 100% rename from hub/requirement/req-zero-downtime-deploy.radical rename to packages/hub-catalogue/catalogue/requirement/req-zero-downtime-deploy.radical diff --git a/packages/hub-catalogue/package.json b/packages/hub-catalogue/package.json new file mode 100644 index 00000000..85679a7c --- /dev/null +++ b/packages/hub-catalogue/package.json @@ -0,0 +1,28 @@ +{ + "name": "@radical/hub-catalogue", + "version": "0.0.0", + "private": true, + "description": "The Architecture Hub's concept catalogue (.radical documents), its validation and index, and the Vite plugin that serves and bundles it.", + "type": "module", + "exports": { + ".": "./src/catalogue.ts", + "./vite": { + "types": "./vite.d.cts", + "default": "./vite.cjs" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@radical/common": "*", + "tsx": "^4.21.0" + }, + "devDependencies": { + "@types/node": "^20.19.39", + "typescript": "^5.3.3", + "vite": "^5.0.10", + "vitest": "^4.1.4" + } +} diff --git a/packages/hub-catalogue/src/catalogue.ts b/packages/hub-catalogue/src/catalogue.ts new file mode 100644 index 00000000..c0050937 --- /dev/null +++ b/packages/hub-catalogue/src/catalogue.ts @@ -0,0 +1,71 @@ +// ─── Hub catalogue ─────────────────────────────────────────────────────────── +// +// The Architecture Hub's concept catalogue: one Radical Studio document per +// concept, at catalogue//.radical (see hubFormat.ts in +// @radical/common). This module reads and validates it and builds the +// summaries index; ./vite serves and bundles it for the Hub and for Studio, +// which ships it for offline Hub import. + +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { summarize, type HubConceptSummary, type HubRadicalDoc } from '@radical/common/hubFormat' + +/** Absolute path of the catalogue shipped with this package. */ +export const CATALOGUE_DIR = fileURLToPath(new URL('../catalogue', import.meta.url)) + +export const HUB_DIR = 'hub' +export const LEGACY_FILE = 'hub-data.json' + +export interface CatalogueEntry { + file: string + doc: HubRadicalDoc +} + +export function readCatalogue(dir: string = CATALOGUE_DIR): CatalogueEntry[] { + const out: CatalogueEntry[] = [] + const walk = (d: string): void => { + for (const name of readdirSync(d).sort()) { + const p = join(d, name) + if (statSync(p).isDirectory()) walk(p) + else if (name.endsWith('.radical')) { + out.push({ file: relative(dir, p).split('\\').join('/'), doc: JSON.parse(readFileSync(p, 'utf8')) }) + } + } + } + walk(dir) + return out +} + +export function validateCatalogue(entries: CatalogueEntry[]): string[] { + const errors: string[] = [] + const ids = new Set() + for (const { file, doc } of entries) { + const meta = doc.hub + if (!meta || typeof meta !== 'object') { errors.push(`${file}: missing "hub" block`); continue } + if (!Array.isArray(doc.nodes) || doc.nodes.length === 0) errors.push(`${file}: "nodes" must be a non-empty array`) + if (!meta.id || !meta.category || !meta.name) errors.push(`${file}: hub.id / hub.category / hub.name are required`) + if (file !== `${meta.category}/${meta.id}.radical`) errors.push(`${file}: expected path ${meta.category}/${meta.id}.radical`) + if (ids.has(meta.id)) errors.push(`${file}: duplicate id "${meta.id}"`) + ids.add(meta.id) + // Every {{KEY}} placeholder must be declared at concept or node level. + const declared = new Set((meta.templateParams ?? []).map((p) => p.key)) + for (const n of doc.nodes ?? []) { + const nodeKeys = new Set([...declared, ...((n.templateParams as Array<{ key: string }> | undefined) ?? []).map((p) => p.key)]) + for (const v of Object.values(n)) { + if (typeof v !== 'string') continue + for (const m of v.matchAll(/\{\{([A-Z0-9_]+)\}\}/g)) { + if (!nodeKeys.has(m[1])) errors.push(`${file}: node "${n.id}" uses undeclared template param {{${m[1]}}}`) + } + } + } + } + for (const { file, doc } of entries) { + for (const ref of doc.hub?.hubRefs ?? []) if (!ids.has(ref)) errors.push(`${file}: hubRefs → unknown concept "${ref}"`) + } + return errors +} + +export function buildIndex(entries: CatalogueEntry[]): HubConceptSummary[] { + return entries.map(({ file, doc }) => summarize(doc, file)) +} diff --git a/packages/hub-catalogue/src/vite.ts b/packages/hub-catalogue/src/vite.ts new file mode 100644 index 00000000..fb8f3ffc --- /dev/null +++ b/packages/hub-catalogue/src/vite.ts @@ -0,0 +1,78 @@ +// ─── Hub catalogue Vite plugin ─────────────────────────────────────────────── +// +// Serves and bundles the catalogue under the /hub/ URL prefix the apps expect: +// +// hub//.radical — the concept documents themselves +// hub/index.json — summaries for browsing (built from the `hub` blocks) +// hub-data.json — legacy single-file catalogue for older desktop builds +// +// In dev these are served from memory by a middleware; at build time this +// plugin explicitly emits every file (the catalogue sits outside Vite's +// publicDir, so nothing copies it there implicitly). `.radical` files get an +// explicit JSON content type (the store rejects non-JSON responses). +// +// Configs import it through ../vite.cjs (the package's `./vite` export). + +import { existsSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import type { Plugin } from 'vite' +import { HUB_INDEX_FILE, toLegacyCatalogue } from '@radical/common/hubFormat' +import { + CATALOGUE_DIR, + HUB_DIR, + LEGACY_FILE, + buildIndex, + readCatalogue, + validateCatalogue, + type CatalogueEntry, +} from './catalogue' + +export function hubCataloguePlugin(opts: { hubDir?: string } = {}): Plugin { + const dir = resolve(opts.hubDir ?? CATALOGUE_DIR) + const load = (): CatalogueEntry[] => { + const entries = readCatalogue(dir) + const errors = validateCatalogue(entries) + if (errors.length) throw new Error(`Hub catalogue invalid:\n ${errors.join('\n ')}`) + return entries + } + const indexJson = (e: CatalogueEntry[]): string => JSON.stringify(buildIndex(e)) + const legacyJson = (e: CatalogueEntry[]): string => JSON.stringify(toLegacyCatalogue(e.map((x) => x.doc))) + + return { + name: 'radical:hub-catalogue', + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = decodeURIComponent((req.url ?? '').split('?')[0]) + const send = (body: string): void => { + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.setHeader('Cache-Control', 'no-store') + res.end(body) + } + try { + if (url === `/${HUB_DIR}/${HUB_INDEX_FILE}`) return send(indexJson(load())) + if (url === `/${LEGACY_FILE}`) return send(legacyJson(load())) + if (url.startsWith(`/${HUB_DIR}/`) && url.endsWith('.radical')) { + const p = resolve(dir, '.' + url.slice(HUB_DIR.length + 1)) + if (!p.startsWith(dir + '/')) { res.statusCode = 403; return res.end() } + if (existsSync(p)) return send(readFileSync(p, 'utf8')) + } + } catch (err) { + res.statusCode = 500 + return res.end(err instanceof Error ? err.message : String(err)) + } + next() + }) + }, + generateBundle() { + const entries = load() + this.emitFile({ type: 'asset', fileName: `${HUB_DIR}/${HUB_INDEX_FILE}`, source: indexJson(entries) }) + this.emitFile({ type: 'asset', fileName: LEGACY_FILE, source: legacyJson(entries) }) + // The concept documents themselves — Vite's publicDir copy doesn't + // reach them since the catalogue lives outside publicDir, so emit each + // one explicitly, verbatim (raw bytes, not the parsed-then-restringified doc). + for (const { file } of entries) { + this.emitFile({ type: 'asset', fileName: `${HUB_DIR}/${file}`, source: readFileSync(join(dir, file), 'utf8') }) + } + }, + } +} diff --git a/tests/hubCatalogue.test.ts b/packages/hub-catalogue/tests/catalogue.test.ts similarity index 83% rename from tests/hubCatalogue.test.ts rename to packages/hub-catalogue/tests/catalogue.test.ts index ed02cede..5fe13979 100644 --- a/tests/hubCatalogue.test.ts +++ b/packages/hub-catalogue/tests/catalogue.test.ts @@ -1,7 +1,7 @@ /** * Hub catalogue — one Radical Studio document per concept. * - * - every top-level hub//.radical is valid (hub block, ids, refs) + * - every catalogue//.radical is valid (hub block, ids, refs) * - index.json summaries carry what the cards / drop-target check need * - doc ↔ concept mapping round-trips * - blueprint mockups form per-actor screen flows (flow groups linked by @@ -12,14 +12,10 @@ * sequences whose steps all exist */ import { describe, it, expect } from 'vitest' -import { resolve } from 'node:path' -import { readCatalogue, validateCatalogue, buildIndex } from '../tools/hubCatalogue' -import { conceptToDoc, docToConcept, summarize, type HubRadicalDoc } from '../src/renderer/src/hub/hubFormat' -import { useDiagramStore } from '../src/renderer/src/store/diagramStore' -import { builtInGovernanceMetamodel, isParentAllowed } from '../src/renderer/src/types/metamodel' -import { sanitizeWireframeSvg } from '../src/renderer/src/ai/mockupWireframe' - -const HUB_DIR = resolve(__dirname, '../hub') +import { readCatalogue, validateCatalogue, buildIndex } from '../src/catalogue' +import { conceptToDoc, docToConcept, summarize, type HubRadicalDoc } from '@radical/common/hubFormat' +import { sanitizeWireframeSvg } from '@radical/common/wireframe' +import { builtInGovernanceMetamodel, isParentAllowed } from '@radical/common/metamodel' const doc: HubRadicalDoc = { hub: { @@ -49,12 +45,12 @@ describe('hubFormat', () => { expect(s.rootTypes).toEqual(['requirement']) expect(s.preview).toEqual({ ears_type: 'ubiquitous', priority: 'must' }) expect(s.templateParams).toEqual([{ key: 'K', label: 'k' }]) - expect((s as Record).nodes).toBeUndefined() + expect((s as unknown as Record).nodes).toBeUndefined() }) }) describe('hub catalogue', () => { - const entries = readCatalogue(HUB_DIR) + const entries = readCatalogue() it('contains concepts and passes validation', () => { expect(entries.length).toBeGreaterThan(50) @@ -79,18 +75,8 @@ describe('hub catalogue', () => { }) }) -describe('studio round-trip', () => { - it('loadDiagram → saveDiagram keeps the hub block (opening a concept in Studio must not strip it)', () => { - const store = useDiagramStore.getState() - store.loadDiagram({ nodes: [], relations: [], hub: doc.hub }) - expect(useDiagramStore.getState().saveDiagram().hub).toEqual(doc.hub) - store.newDiagram() - expect(useDiagramStore.getState().saveDiagram().hub).toBeUndefined() - }) -}) - describe('blueprint mockups', () => { - const blueprints = readCatalogue(HUB_DIR).filter((e) => e.doc.hub.category === 'blueprint') + const blueprints = readCatalogue().filter((e) => e.doc.hub.category === 'blueprint') const mm = builtInGovernanceMetamodel() it.each(blueprints.map((e) => [e.file, e.doc] as const))('%s has screen flows with sanitised wireframes', (_file, doc) => { @@ -112,19 +98,19 @@ describe('blueprint mockups', () => { } for (const r of doc.relations ?? []) { - const src = byId.get(r.sourceId) - const tgt = byId.get(r.targetId) + const src = byId.get(String(r.sourceId)) + const tgt = byId.get(String(r.targetId)) expect(src, `${r.id}: missing source`).toBeDefined() expect(tgt, `${r.id}: missing target`).toBeDefined() if (src!.type !== 'mockup') continue - const pairs = mm.relationTypes[r.relationType ?? '']?.allowedPairs ?? [] + const pairs = mm.relationTypes[String(r.relationType ?? '')]?.allowedPairs ?? [] expect(pairs.some((p) => p.from === src!.type && p.to === tgt!.type), `${r.id}: ${r.relationType} ${src!.type} → ${tgt!.type}`).toBe(true) } }) }) describe('blueprint views and sequences', () => { - const blueprints = readCatalogue(HUB_DIR).filter((e) => e.doc.hub.category === 'blueprint') + const blueprints = readCatalogue().filter((e) => e.doc.hub.category === 'blueprint') it.each(blueprints.map((e) => [e.file, e.doc] as const))('%s has consistent views and sequences', (_file, doc) => { const nodeIds = new Set(doc.nodes.map((n) => String(n.id))) diff --git a/packages/hub-catalogue/tsconfig.json b/packages/hub-catalogue/tsconfig.json new file mode 100644 index 00000000..c8cf68af --- /dev/null +++ b/packages/hub-catalogue/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*", "tests/**/*"] +} diff --git a/packages/hub-catalogue/vite.cjs b/packages/hub-catalogue/vite.cjs new file mode 100644 index 00000000..93738772 --- /dev/null +++ b/packages/hub-catalogue/vite.cjs @@ -0,0 +1,10 @@ +// Entry point for Vite configs, in plain CommonJS: the Vite / electron-vite +// config loader hands bare imports to Node, which cannot run this package's +// TypeScript. The plugin itself (src/vite.ts) is loaded through tsx. Vite +// accepts a Promise of a plugin in `plugins`. +const { tsImport } = require('tsx/esm/api') + +exports.hubCataloguePlugin = async function hubCataloguePlugin(opts) { + const mod = await tsImport('./src/vite.ts', __filename) + return mod.hubCataloguePlugin(opts) +} diff --git a/packages/hub-catalogue/vite.d.cts b/packages/hub-catalogue/vite.d.cts new file mode 100644 index 00000000..39a0d987 --- /dev/null +++ b/packages/hub-catalogue/vite.d.cts @@ -0,0 +1,5 @@ +import type { Plugin } from 'vite' + +/** Serves the catalogue under /hub/ in dev and emits it at build time. + * `hubDir` defaults to the catalogue shipped with this package. */ +export declare function hubCataloguePlugin(opts?: { hubDir?: string }): Promise diff --git a/packages/layout/README.md b/packages/layout/README.md new file mode 100644 index 00000000..d9cd7da7 --- /dev/null +++ b/packages/layout/README.md @@ -0,0 +1,23 @@ +# @radical/layout + +Smart Layout and the other headless layout engines: the ELK ensemble, the +Radical semantic C4 layout, crossing minimisation, simulated-annealing +refinement, port allocation and edge routing. Depends on `@radical/common` +and `elkjs` only. + +Public entry points are the ones listed in `exports` in package.json +(`smartLayout`, `elkLayout`, `radicalLayout`, `crossingOpt`, `geometry`, +`portAllocator`, `edgeRouting`, `side`). The annealing refinement, score +weights, ELK spacing and finalising steps are internal. + +Studio keeps the browser-specific parts: the Web Worker wrapper +(`smartLayout.worker.ts`, `smartLayoutRunner.ts`) and the live webcola +physics (`liveColaLayout.ts`). + +Smart Layout is the product's core differentiator. Before and after changing +it, compare the composite score across several fixtures: + +```bash +npm test -w @radical/layout +node --import tsx tests/visual/layoutHarness.mjs # from packages/layout +``` diff --git a/packages/layout/package.json b/packages/layout/package.json new file mode 100644 index 00000000..72367994 --- /dev/null +++ b/packages/layout/package.json @@ -0,0 +1,29 @@ +{ + "name": "@radical/layout", + "version": "0.0.0", + "private": true, + "description": "Smart Layout and the other headless layout engines (ELK ensemble, Radical semantic C4 layout, crossing minimisation, annealing, port allocation, edge routing).", + "type": "module", + "exports": { + "./smartLayout": "./src/smartLayout.ts", + "./elkLayout": "./src/elkLayout.ts", + "./radicalLayout": "./src/radicalLayout.ts", + "./crossingOpt": "./src/crossingOpt.ts", + "./geometry": "./src/geometry.ts", + "./portAllocator": "./src/portAllocator.ts", + "./edgeRouting": "./src/edgeRouting.ts", + "./side": "./src/side.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@radical/common": "*", + "elkjs": "^0.9.3" + }, + "devDependencies": { + "typescript": "^5.3.3", + "vitest": "^4.1.4" + } +} diff --git a/src/renderer/src/layout/annealing.ts b/packages/layout/src/annealing.ts similarity index 99% rename from src/renderer/src/layout/annealing.ts rename to packages/layout/src/annealing.ts index c3b09d84..01ed463d 100644 --- a/src/renderer/src/layout/annealing.ts +++ b/packages/layout/src/annealing.ts @@ -18,7 +18,7 @@ * - **Incremental energy.** ProxyEnergy re-evaluates only the edges and * node pairs touched by a move instead of the whole diagram. */ -import type { C4Node, C4Relation } from '../types/c4' +import type { C4Node, C4Relation } from '@radical/common/c4' import { W_CROSS, W_OVERDRAW, W_LMEAN, W_LMAX, W_ASPECT, LEN_MEAN_KNEE, LEN_MAX_KNEE, overlapCost, aspectPenalty, diff --git a/src/renderer/src/layout/crossingOpt.ts b/packages/layout/src/crossingOpt.ts similarity index 99% rename from src/renderer/src/layout/crossingOpt.ts rename to packages/layout/src/crossingOpt.ts index 5f02ca6b..bbbb98b6 100644 --- a/src/renderer/src/layout/crossingOpt.ts +++ b/packages/layout/src/crossingOpt.ts @@ -38,8 +38,8 @@ * Cohen-Sutherland line-rect clipping (segment-rectangle intersection) */ -import type { C4Node, C4Relation } from '../types/c4' -import { COLLAPSED_WIDTH, COLLAPSED_HEIGHT, isContainerType } from '../types/c4' +import type { C4Node, C4Relation } from '@radical/common/c4' +import { COLLAPSED_WIDTH, COLLAPSED_HEIGHT, isContainerType } from '@radical/common/c4' // ── Geometry primitives ─────────────────────────────────────────────────────── diff --git a/src/renderer/src/layout/edgeRouting.ts b/packages/layout/src/edgeRouting.ts similarity index 99% rename from src/renderer/src/layout/edgeRouting.ts rename to packages/layout/src/edgeRouting.ts index f35b705a..b2ae7a9f 100644 --- a/src/renderer/src/layout/edgeRouting.ts +++ b/packages/layout/src/edgeRouting.ts @@ -2,7 +2,7 @@ * Orthogonal edge routing with obstacle avoidance. * Uses A* pathfinding on a coarse grid to find paths around diagram nodes. */ -import { Position } from 'reactflow' +import { Position } from './side' // ── Types ───────────────────────────────────────────────────────────── diff --git a/src/renderer/src/layout/elkLayout.ts b/packages/layout/src/elkLayout.ts similarity index 99% rename from src/renderer/src/layout/elkLayout.ts rename to packages/layout/src/elkLayout.ts index 2ff2287c..bde2237c 100644 --- a/src/renderer/src/layout/elkLayout.ts +++ b/packages/layout/src/elkLayout.ts @@ -14,7 +14,7 @@ import ELK from 'elkjs/lib/elk.bundled.js' import type { ElkNode, ElkExtendedEdge, LayoutOptions } from 'elkjs' -import { C4Node, C4Relation, PositionMap, COLLAPSED_HEIGHT, COLLAPSED_WIDTH, isContainerType } from '../types/c4' +import { C4Node, C4Relation, PositionMap, COLLAPSED_HEIGHT, COLLAPSED_WIDTH, isContainerType } from '@radical/common/c4' import { ELK_ROOT_SPACING, ELK_CHILD_SPACING } from './elkSpacingBase' import { projectToVisibleGraph } from './geometry' diff --git a/src/renderer/src/layout/elkSpacingBase.ts b/packages/layout/src/elkSpacingBase.ts similarity index 100% rename from src/renderer/src/layout/elkSpacingBase.ts rename to packages/layout/src/elkSpacingBase.ts diff --git a/src/renderer/src/layout/geometry.ts b/packages/layout/src/geometry.ts similarity index 98% rename from src/renderer/src/layout/geometry.ts rename to packages/layout/src/geometry.ts index db1010a8..325fbf83 100644 --- a/src/renderer/src/layout/geometry.ts +++ b/packages/layout/src/geometry.ts @@ -8,7 +8,7 @@ * silently mis-size such a node while the others handled it correctly. * Consolidated here so every caller shares one (correct) definition. */ -import { C4Node, C4Relation, COLLAPSED_HEIGHT, COLLAPSED_WIDTH, NODE_SIZES, isContainerType } from '../types/c4' +import { C4Node, C4Relation, COLLAPSED_HEIGHT, COLLAPSED_WIDTH, NODE_SIZES, isContainerType } from '@radical/common/c4' export function effectiveWidth(n: C4Node): number { if (isContainerType(n.type) && n.collapsed) return COLLAPSED_WIDTH[n.type] diff --git a/src/renderer/src/layout/layoutFinalize.ts b/packages/layout/src/layoutFinalize.ts similarity index 91% rename from src/renderer/src/layout/layoutFinalize.ts rename to packages/layout/src/layoutFinalize.ts index 7f0437aa..68ba9b20 100644 --- a/src/renderer/src/layout/layoutFinalize.ts +++ b/packages/layout/src/layoutFinalize.ts @@ -10,7 +10,7 @@ * Separation is deterministic: pairs are visited in a fixed order and each * overlap is resolved along its axis of least penetration, split evenly. */ -import { C4Node, NODE_SIZES, PositionMap } from '../types/c4' +import { C4Node, NODE_SIZES, PositionMap } from '@radical/common/c4' import { compoundPadding } from './geometry' /** Minimum clear space between root-level nodes. */ @@ -77,15 +77,6 @@ export function separateBoxes(boxes: Box[], gap: number, maxPasses = 200): void } } -/** - * Fit every compound parent to its children (deepest first). Children are - * shifted so their bounding box starts at the padding origin, and the parent - * is resized to hug them. - */ -export function fitParentsToChildren(nodes: Record, positions: PositionMap): PositionMap { - return finalize(nodes, positions, false) -} - /** Separate overlapping siblings at every level, fit parents, separate roots. */ export function finalizeLayout(nodes: Record, positions: PositionMap): PositionMap { return finalize(nodes, positions, true) diff --git a/src/renderer/src/layout/portAllocator.ts b/packages/layout/src/portAllocator.ts similarity index 99% rename from src/renderer/src/layout/portAllocator.ts rename to packages/layout/src/portAllocator.ts index 4b668adb..060a47c0 100644 --- a/src/renderer/src/layout/portAllocator.ts +++ b/packages/layout/src/portAllocator.ts @@ -16,7 +16,7 @@ * Cache: keyed on the (nodeInternals, edges) reference pair so React Flow's * stable references between renders give us O(1) reuse. */ -import { Position } from 'reactflow' +import { Position } from './side' interface NodeView { id: string diff --git a/src/renderer/src/layout/radicalLayout.ts b/packages/layout/src/radicalLayout.ts similarity index 99% rename from src/renderer/src/layout/radicalLayout.ts rename to packages/layout/src/radicalLayout.ts index 857b582f..1b114e2f 100644 --- a/src/renderer/src/layout/radicalLayout.ts +++ b/packages/layout/src/radicalLayout.ts @@ -15,7 +15,7 @@ * This is NOT a generic graph layout — it understands C4 hierarchy. */ -import { C4Node, C4Relation, PositionMap, NODE_SIZES } from '../types/c4' +import { C4Node, C4Relation, PositionMap, NODE_SIZES } from '@radical/common/c4' import { effectiveWidth, effectiveHeight, compoundPadding, projectToVisibleGraph } from './geometry' // ─── Configuration ─────────────────────────────────────────────────────────── diff --git a/src/renderer/src/layout/scoreWeights.ts b/packages/layout/src/scoreWeights.ts similarity index 100% rename from src/renderer/src/layout/scoreWeights.ts rename to packages/layout/src/scoreWeights.ts diff --git a/packages/layout/src/side.ts b/packages/layout/src/side.ts new file mode 100644 index 00000000..fc644ee1 --- /dev/null +++ b/packages/layout/src/side.ts @@ -0,0 +1,14 @@ +// ─── Node sides ────────────────────────────────────────────────────────────── +// +// The side of a node an edge attaches to. The values match reactflow's +// `Position` enum ('top' | 'right' | 'bottom' | 'left') so the canvas can pass +// them straight through, while this package stays free of UI dependencies. + +export const Position = { + Top: 'top', + Right: 'right', + Bottom: 'bottom', + Left: 'left', +} as const + +export type Position = (typeof Position)[keyof typeof Position] diff --git a/src/renderer/src/layout/smartLayout.ts b/packages/layout/src/smartLayout.ts similarity index 99% rename from src/renderer/src/layout/smartLayout.ts rename to packages/layout/src/smartLayout.ts index ab5dde35..9904ea33 100644 --- a/src/renderer/src/layout/smartLayout.ts +++ b/packages/layout/src/smartLayout.ts @@ -40,9 +40,9 @@ */ import type { LayoutOptions } from 'elkjs' -import { Position } from 'reactflow' -import type { C4Node, C4Relation, PositionMap } from '../types/c4' -import type { Metamodel } from '../types/metamodel' +import { Position } from './side' +import type { C4Node, C4Relation, PositionMap } from '@radical/common/c4' +import type { Metamodel } from '@radical/common/metamodel' import { applyRadicalLayout } from './radicalLayout' import { minimizeCrossings, computeLayoutMetrics, type LayoutMetrics } from './crossingOpt' import { pickSides } from './portAllocator' diff --git a/tests/annealing.test.ts b/packages/layout/tests/annealing.test.ts similarity index 97% rename from tests/annealing.test.ts rename to packages/layout/tests/annealing.test.ts index 87bebdb8..c1f1aa1a 100644 --- a/tests/annealing.test.ts +++ b/packages/layout/tests/annealing.test.ts @@ -9,8 +9,8 @@ import { describe, it, expect } from 'vitest' import { anneal, buildLayoutGraph, createRng, graphSeed, ProxyEnergy, type LayoutGraph, -} from '../src/renderer/src/layout/annealing' -import type { C4Node, C4Relation } from '../src/renderer/src/types/c4' +} from '../src/annealing' +import type { C4Node, C4Relation } from '@radical/common/c4' function node(id: string, x: number, y: number, w = 200, h = 100, parentId?: string): C4Node { return { id, type: parentId ? 'container' : 'system', label: id, x, y, width: w, height: h, collapsed: false, parentId } diff --git a/tests/layoutPipeline.test.ts b/packages/layout/tests/layoutPipeline.test.ts similarity index 93% rename from tests/layoutPipeline.test.ts rename to packages/layout/tests/layoutPipeline.test.ts index 20018c6a..87564bf6 100644 --- a/tests/layoutPipeline.test.ts +++ b/packages/layout/tests/layoutPipeline.test.ts @@ -4,12 +4,12 @@ * one that gets rendered (finalizeLayout). */ import { describe, it, expect } from 'vitest' -import { projectToVisibleGraph } from '../src/renderer/src/layout/geometry' -import { finalizeLayout, separateBoxes, ROOT_GAP } from '../src/renderer/src/layout/layoutFinalize' -import { applyElkLayout } from '../src/renderer/src/layout/elkLayout' -import { minimizeCrossings, totalLayoutCost } from '../src/renderer/src/layout/crossingOpt' -import { runSmartLayoutELKPhase, type SmartLayoutProgress } from '../src/renderer/src/layout/smartLayout' -import { COLLAPSED_HEIGHT, COLLAPSED_WIDTH, NODE_SIZES, type C4Node, type C4Relation } from '../src/renderer/src/types/c4' +import { projectToVisibleGraph } from '../src/geometry' +import { finalizeLayout, separateBoxes, ROOT_GAP } from '../src/layoutFinalize' +import { applyElkLayout } from '../src/elkLayout' +import { minimizeCrossings, totalLayoutCost } from '../src/crossingOpt' +import { runSmartLayoutELKPhase, type SmartLayoutProgress } from '../src/smartLayout' +import { COLLAPSED_HEIGHT, COLLAPSED_WIDTH, NODE_SIZES, type C4Node, type C4Relation } from '@radical/common/c4' function node(id: string, type: C4Node['type'], extras: Partial = {}): C4Node { return { id, type, label: id, x: 0, y: 0, collapsed: false, ...NODE_SIZES[type], ...extras } diff --git a/tests/radicalLayout.test.ts b/packages/layout/tests/radicalLayout.test.ts similarity index 99% rename from tests/radicalLayout.test.ts rename to packages/layout/tests/radicalLayout.test.ts index 640f8eef..364b07ae 100644 --- a/tests/radicalLayout.test.ts +++ b/packages/layout/tests/radicalLayout.test.ts @@ -18,10 +18,10 @@ */ import { describe, it, expect } from 'vitest' -import { applyRadicalLayout } from '../src/renderer/src/layout/radicalLayout' -import { computeLayoutMetrics } from '../src/renderer/src/layout/crossingOpt' -import type { C4Node, C4Relation, PositionMap } from '../src/renderer/src/types/c4' -import { NODE_SIZES } from '../src/renderer/src/types/c4' +import { applyRadicalLayout } from '../src/radicalLayout' +import { computeLayoutMetrics } from '../src/crossingOpt' +import type { C4Node, C4Relation, PositionMap } from '@radical/common/c4' +import { NODE_SIZES } from '@radical/common/c4' // ── Helper: build the full sample diagram ──────────────────────────────────── @@ -401,7 +401,7 @@ describe('Radical Layout — Full Sample Diagram', () => { .filter((n) => { let cur: C4Node | undefined = n while (cur?.parentId) { - const parent = nodes[cur.parentId] + const parent: C4Node | undefined = nodes[cur.parentId] if (parent?.collapsed) return false cur = parent } diff --git a/tests/saThroughput.bench.mjs b/packages/layout/tests/saThroughput.bench.mjs similarity index 98% rename from tests/saThroughput.bench.mjs rename to packages/layout/tests/saThroughput.bench.mjs index 1ac480cc..2c1c4b5f 100644 --- a/tests/saThroughput.bench.mjs +++ b/packages/layout/tests/saThroughput.bench.mjs @@ -10,7 +10,7 @@ */ const { runSmartLayoutCore } = await import( - '../src/renderer/src/layout/smartLayout.ts' + '../src/smartLayout.ts' ) function N(id, type, label, w, h, extras = {}) { diff --git a/tests/smartLayout.test.ts b/packages/layout/tests/smartLayout.test.ts similarity index 98% rename from tests/smartLayout.test.ts rename to packages/layout/tests/smartLayout.test.ts index 6a4ede6a..731c118a 100644 --- a/tests/smartLayout.test.ts +++ b/packages/layout/tests/smartLayout.test.ts @@ -9,8 +9,8 @@ * the end-to-end ensemble never hands back something worse than it started. */ import { describe, it, expect } from 'vitest' -import { computeCompositeScore, runSmartLayoutCore } from '../src/renderer/src/layout/smartLayout' -import type { C4Node, C4Relation, PositionMap } from '../src/renderer/src/types/c4' +import { computeCompositeScore, runSmartLayoutCore } from '../src/smartLayout' +import type { C4Node, C4Relation, PositionMap } from '@radical/common/c4' function node(id: string, x: number, y: number, w = 200, h = 100, parentId?: string): C4Node { return { id, type: 'system', label: id, x, y, width: w, height: h, collapsed: false, parentId } diff --git a/tests/smartLayoutScoring.test.mjs b/packages/layout/tests/smartLayoutScoring.test.mjs similarity index 98% rename from tests/smartLayoutScoring.test.mjs rename to packages/layout/tests/smartLayoutScoring.test.mjs index 28c71102..be1de82f 100644 --- a/tests/smartLayoutScoring.test.mjs +++ b/packages/layout/tests/smartLayoutScoring.test.mjs @@ -9,7 +9,7 @@ * instead of being a standalone script nobody runs. */ import { describe, it, expect } from 'vitest' -import { computeCompositeScore } from '../src/renderer/src/layout/smartLayout' +import { computeCompositeScore } from '../src/smartLayout.ts' // ─── Scene ────────────────────────────────────────────────────────────────── // diff --git a/tests/visual/layoutHarness.mjs b/packages/layout/tests/visual/layoutHarness.mjs similarity index 97% rename from tests/visual/layoutHarness.mjs rename to packages/layout/tests/visual/layoutHarness.mjs index 2492f42b..e6cca378 100644 --- a/tests/visual/layoutHarness.mjs +++ b/packages/layout/tests/visual/layoutHarness.mjs @@ -1,5 +1,5 @@ /** - * Visual harness: runs `runSmartLayout` on a set of scenarios, renders each + * Visual harness: runs `runSmartLayoutCore` on a set of scenarios, renders each * resulting layout to an SVG file under tests/visual/out-*.svg, and prints a * compact metrics breakdown. * @@ -15,10 +15,10 @@ import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) -const { runSmartLayout, computeCompositeScore } = await import( - '../../src/renderer/src/layout/smartLayout.ts' +const { runSmartLayoutCore, computeCompositeScore } = await import( + '../../src/smartLayout.ts' ) -const { pickSides } = await import('../../src/renderer/src/layout/portAllocator.ts') +const { pickSides } = await import('../../src/portAllocator.ts') // ─── Scenarios ──────────────────────────────────────────────────────────── @@ -306,7 +306,7 @@ mkdirSync(here, { recursive: true }) for (const [name, scene] of scenarios) { const t0 = Date.now() - const result = await runSmartLayout(scene.nodes, scene.relations) + const result = await runSmartLayoutCore(scene.nodes, scene.relations) const dt = Date.now() - t0 const projected = applyPositions(scene.nodes, result.winner.positions) const score = computeCompositeScore(projected, scene.relations) diff --git a/packages/layout/tsconfig.json b/packages/layout/tsconfig.json new file mode 100644 index 00000000..75880607 --- /dev/null +++ b/packages/layout/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*", "tests/**/*"] +} diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 00000000..2ea47ad0 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,24 @@ +# @radical/ui + +The canvas, properties panel, Wiki and Table views, and the diagram store, +shared by Studio (apps/studio) and the read-only Hub viewer (apps/hub). + +It has no documents, host bridge or AI. Apps plug those in: + +- `store/documentBackend`: `configureDocumentBackend()` gives the store the + document to start from and lazily loaded node bodies. Call it before the + store module is first imported, because the store builds its initial state + on import. Studio does this in `persistence/configureDocuments.ts`. Saving is + the app's job: subscribe to the store (Studio's `persistence/autosave.ts`). +- `components/wireframeGeneration`: `setWireframeGenerator()` enables + "Generate wireframe" on mockups. Studio registers its AI-backed generator. +- `window.__RADICAL_PROFILE = 'viewer'`, set before any module runs, starts + the store empty and read-only (the Hub). + +Entry points are listed in `exports` in package.json. `@radical/ui/viewer` +bundles everything the Hub needs; `@radical/ui/styles.css` is the stylesheet +for these components (Studio adds its own `studio.css` after it); `@radical/ui/testing/setup` stubs `window` and +`requestAnimationFrame` for tests under node. + +`npm test -w @radical/ui` runs the store and component tests under node, without +any app's documents or AI. diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 00000000..ade51ee9 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,52 @@ +{ + "name": "@radical/ui", + "version": "0.0.0", + "private": true, + "description": "The canvas, panels, views and diagram store shared by Studio and the read-only Hub viewer. No documents, host or AI: apps plug those in (store/documentBackend, components/wireframeGeneration).", + "type": "module", + "exports": { + "./viewer": "./src/viewer.ts", + "./styles.css": "./src/index.css", + "./store/diagramStore": "./src/store/diagramStore.ts", + "./store/hubStore": "./src/store/hubStore.ts", + "./store/documentBackend": "./src/store/documentBackend.ts", + "./components/Canvas": "./src/components/Canvas.tsx", + "./components/WikiView": "./src/components/WikiView.tsx", + "./components/TableView": "./src/components/TableView.tsx", + "./components/RightPanel": "./src/components/RightPanel.tsx", + "./components/NotificationHost": "./src/components/NotificationHost.tsx", + "./components/SmartLayoutButton": "./src/components/SmartLayoutButton.tsx", + "./components/ErrorBoundary": "./src/components/ErrorBoundary.tsx", + "./components/wireframeGeneration": "./src/components/wireframeGeneration.ts", + "./hooks/useOutsideClick": "./src/hooks/useOutsideClick.ts", + "./hub/conceptViews": "./src/hub/conceptViews.ts", + "./types/hubTheme": "./src/types/hubTheme.ts", + "./layout/smartLayoutRunner": "./src/layout/smartLayoutRunner.ts", + "./layout/liveColaLayout": "./src/layout/liveColaLayout.ts", + "./studioSettings": "./src/studioSettings.ts", + "./testing/setup": "./testing/setup.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@radical/common": "*", + "@radical/layout": "*", + "d3-dispatch": "^3.0.1", + "d3-drag": "^3.0.0", + "d3-timer": "^3.0.1", + "immer": "^10.0.3", + "react": "^18.2.0", + "reactflow": "^11.11.4", + "webcola": "^3.4.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "typescript": "^5.3.3", + "vite": "^5.0.10", + "vitest": "^4.1.4" + } +} diff --git a/src/renderer/src/components/Canvas.tsx b/packages/ui/src/components/Canvas.tsx similarity index 99% rename from src/renderer/src/components/Canvas.tsx rename to packages/ui/src/components/Canvas.tsx index 6298ee00..0d8247b7 100644 --- a/src/renderer/src/components/Canvas.tsx +++ b/packages/ui/src/components/Canvas.tsx @@ -15,8 +15,8 @@ import { useDiagramStore } from '../store/diagramStore' import { PersonNode, SystemNode, ContainerNode, ComponentNode, DatabaseNode, WebAppNode, QueueNode, DomainNode, GroupNode, AdrNode, FitnessFnNode, RequirementNode, ScenarioNode, BlueprintNode, MockupNode } from './nodes/C4Nodes' import { RelationEdge } from './edges/RelationEdge' import { DeleteConfirmDialog } from './DeleteConfirmDialog' -import { C4ElementType, NODE_SIZES, COLLAPSED_HEIGHT } from '../types/c4' -import { isParentAllowed, isRelationAllowed } from '../types/metamodel' +import { C4ElementType, NODE_SIZES, COLLAPSED_HEIGHT } from '@radical/common/c4' +import { isParentAllowed, isRelationAllowed } from '@radical/common/metamodel' // Node and edge type registrations const nodeTypes: NodeTypes = { diff --git a/src/renderer/src/components/DeleteConfirmDialog.tsx b/packages/ui/src/components/DeleteConfirmDialog.tsx similarity index 100% rename from src/renderer/src/components/DeleteConfirmDialog.tsx rename to packages/ui/src/components/DeleteConfirmDialog.tsx diff --git a/src/renderer/src/components/EarsQuickEntry.tsx b/packages/ui/src/components/EarsQuickEntry.tsx similarity index 97% rename from src/renderer/src/components/EarsQuickEntry.tsx rename to packages/ui/src/components/EarsQuickEntry.tsx index a7f2a93a..2cab6c2d 100644 --- a/src/renderer/src/components/EarsQuickEntry.tsx +++ b/packages/ui/src/components/EarsQuickEntry.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react' -import { parseEarsSentence } from '../types/metamodel' +import { parseEarsSentence } from '@radical/common/metamodel' const EARS_TYPE_LABEL: Record = { ubiquitous: 'ubiquitous', diff --git a/src/renderer/src/components/ErrorBoundary.tsx b/packages/ui/src/components/ErrorBoundary.tsx similarity index 100% rename from src/renderer/src/components/ErrorBoundary.tsx rename to packages/ui/src/components/ErrorBoundary.tsx diff --git a/src/renderer/src/components/MockupWireframe.tsx b/packages/ui/src/components/MockupWireframe.tsx similarity index 88% rename from src/renderer/src/components/MockupWireframe.tsx rename to packages/ui/src/components/MockupWireframe.tsx index 684ea923..e757f9f5 100644 --- a/src/renderer/src/components/MockupWireframe.tsx +++ b/packages/ui/src/components/MockupWireframe.tsx @@ -1,12 +1,13 @@ // ─── Mockup wireframe section ─────────────────────────────────────────────── // Preview of a Mockup node's AI-generated low-fi wireframe plus its actions // (generate / regenerate, remove, open the external design link). Shared by -// the properties panel and the Wiki element page. +// the properties panel and the Wiki element page. Generation is offered only +// when the app registered a generator (see wireframeGeneration.ts). import React, { useEffect, useRef, useState } from 'react' import { useDiagramStore } from '../store/diagramStore' -import { loadAISettings } from '../ai/settings' -import { generateWireframe, wireframeDataUri } from '../ai/mockupWireframe' +import { wireframeGenerator } from './wireframeGeneration' +import { wireframeDataUri } from '@radical/common/wireframe' export function isHttpUrl(value: string): boolean { try { @@ -43,10 +44,11 @@ export function MockupWireframe({ if (!node) return null const wireframe = typeof node.wireframe === 'string' ? node.wireframe : '' const link = typeof node.link === 'string' ? node.link.trim() : '' - const aiEnabled = loadAISettings().enabled + const generator = wireframeGenerator() + const aiEnabled = generator?.enabled() ?? false const generate = async (): Promise => { - const settings = loadAISettings() + if (!generator) return abortRef.current?.abort() const ac = new AbortController() abortRef.current = ac @@ -54,7 +56,7 @@ export function MockupWireframe({ setStatus(null) try { const { c4Nodes, c4Relations } = useDiagramStore.getState() - const { svg, usage } = await generateWireframe(nodeId, c4Nodes, c4Relations, settings, ac.signal) + const { svg, usage } = await generator.generate(nodeId, c4Nodes, c4Relations, ac.signal) updateNode(nodeId, { wireframe: svg } as Parameters[1]) setStatus(usage ? { kind: 'info', text: `${usage.inputTokens + usage.outputTokens} tokens` } : null) } catch (err) { diff --git a/src/renderer/src/components/NotificationHost.tsx b/packages/ui/src/components/NotificationHost.tsx similarity index 100% rename from src/renderer/src/components/NotificationHost.tsx rename to packages/ui/src/components/NotificationHost.tsx diff --git a/src/renderer/src/components/RightPanel.tsx b/packages/ui/src/components/RightPanel.tsx similarity index 99% rename from src/renderer/src/components/RightPanel.tsx rename to packages/ui/src/components/RightPanel.tsx index 5688af13..7c86695f 100644 --- a/src/renderer/src/components/RightPanel.tsx +++ b/packages/ui/src/components/RightPanel.tsx @@ -1,7 +1,7 @@ import React, { ChangeEvent, useState, useMemo, useEffect, useRef, useCallback } from 'react' import { useDiagramStore, nodeEffectivelyCollapsedInView } from '../store/diagramStore' -import { C4ElementType, NODE_COLORS, TYPE_LABELS, TYPE_ICON_PATHS, NODE_FG, isContainerType } from '../types/c4' -import { resolveEarsSubject } from '../types/metamodel' +import { C4ElementType, NODE_COLORS, TYPE_LABELS, TYPE_ICON_PATHS, NODE_FG, isContainerType } from '@radical/common/c4' +import { resolveEarsSubject } from '@radical/common/metamodel' import { EarsQuickEntry } from './EarsQuickEntry' import type { HubImportRecord } from '../store/hubStore' import { MockupWireframe } from './MockupWireframe' diff --git a/src/renderer/src/components/SmartLayoutButton.tsx b/packages/ui/src/components/SmartLayoutButton.tsx similarity index 100% rename from src/renderer/src/components/SmartLayoutButton.tsx rename to packages/ui/src/components/SmartLayoutButton.tsx diff --git a/src/renderer/src/components/TableView.tsx b/packages/ui/src/components/TableView.tsx similarity index 98% rename from src/renderer/src/components/TableView.tsx rename to packages/ui/src/components/TableView.tsx index 067b598f..2d411192 100644 --- a/src/renderer/src/components/TableView.tsx +++ b/packages/ui/src/components/TableView.tsx @@ -1,9 +1,9 @@ import React, { useState, useCallback, useMemo } from 'react' import { useDiagramStore } from '../store/diagramStore' -import type { C4Node, C4Relation, C4ElementType } from '../types/c4' -import { NODE_COLORS, NODE_FG, TYPE_LABELS, NODE_SIZES } from '../types/c4' -import type { Metamodel, PropertyDef } from '../types/metamodel' -import { isParentAllowed, composeEarsSentence, resolveEarsSubject } from '../types/metamodel' +import type { C4Node, C4Relation, C4ElementType } from '@radical/common/c4' +import { NODE_COLORS, NODE_FG, TYPE_LABELS, NODE_SIZES } from '@radical/common/c4' +import type { Metamodel, PropertyDef } from '@radical/common/metamodel' +import { isParentAllowed, composeEarsSentence, resolveEarsSubject } from '@radical/common/metamodel' // ─── Column definitions ────────────────────────────────────────────────────── // diff --git a/src/renderer/src/components/WikiView.tsx b/packages/ui/src/components/WikiView.tsx similarity index 99% rename from src/renderer/src/components/WikiView.tsx rename to packages/ui/src/components/WikiView.tsx index 1398eb6d..16e0e63e 100644 --- a/src/renderer/src/components/WikiView.tsx +++ b/packages/ui/src/components/WikiView.tsx @@ -1,10 +1,10 @@ import React, { useMemo, useState, useRef, useEffect, useLayoutEffect } from 'react' import { useDiagramStore } from '../store/diagramStore' -import { isParentAllowed, isRelationAllowed, isPropertyVisible, resolveEarsSubject, PropertyDef } from '../types/metamodel' +import { isParentAllowed, isRelationAllowed, isPropertyVisible, resolveEarsSubject, PropertyDef } from '@radical/common/metamodel' import { useOutsideClick } from '../hooks/useOutsideClick' import { EarsQuickEntry } from './EarsQuickEntry' import { MockupWireframe } from './MockupWireframe' -import { wireframeDataUri } from '../ai/mockupWireframe' +import { wireframeDataUri } from '@radical/common/wireframe' import { loadStudioSettings, STUDIO_SETTINGS_CHANGED_EVENT } from '../studioSettings' import { C4Node, @@ -15,7 +15,7 @@ import { NODE_SIZES, TYPE_LABELS, TYPE_ICON_PATHS, -} from '../types/c4' +} from '@radical/common/c4' type Metamodel = ReturnType['metamodel'] type AddNode = ReturnType['addNode'] diff --git a/src/renderer/src/components/edges/RelationEdge.tsx b/packages/ui/src/components/edges/RelationEdge.tsx similarity index 97% rename from src/renderer/src/components/edges/RelationEdge.tsx rename to packages/ui/src/components/edges/RelationEdge.tsx index 76fb9832..07f35645 100644 --- a/src/renderer/src/components/edges/RelationEdge.tsx +++ b/packages/ui/src/components/edges/RelationEdge.tsx @@ -7,9 +7,9 @@ import { useStore, useStoreApi, } from 'reactflow' -import { C4EdgeRFData } from '../../types/c4' -import { computeRoutedEdge, RoutingObstacle } from '../../layout/edgeRouting' -import { allocatePorts } from '../../layout/portAllocator' +import { C4EdgeRFData } from '@radical/common/c4' +import { computeRoutedEdge, RoutingObstacle } from '@radical/layout/edgeRouting' +import { allocatePorts } from '@radical/layout/portAllocator' import { useDiagramStore } from '../../store/diagramStore' // ─── Floating-edge helpers ──────────────────────────────────────────────────── @@ -225,8 +225,9 @@ export const RelationEdge = memo( let srcSide: Position, tgtSide: Position let sp: { x: number; y: number }, tp: { x: number; y: number } if (alloc) { - srcSide = alloc.sourceSide - tgtSide = alloc.targetSide + // @radical/layout's sides carry the same string values as reactflow's enum + srcSide = alloc.sourceSide as Position + tgtSide = alloc.targetSide as Position sp = alloc.sourcePoint tp = alloc.targetPoint } else { diff --git a/src/renderer/src/components/nodes/C4Nodes.tsx b/packages/ui/src/components/nodes/C4Nodes.tsx similarity index 99% rename from src/renderer/src/components/nodes/C4Nodes.tsx rename to packages/ui/src/components/nodes/C4Nodes.tsx index f2fa9d06..4850078c 100644 --- a/src/renderer/src/components/nodes/C4Nodes.tsx +++ b/packages/ui/src/components/nodes/C4Nodes.tsx @@ -1,9 +1,9 @@ import React, { memo, useCallback } from 'react' import { NodeProps, Handle, Position } from 'reactflow' -import { C4NodeRFData, NODE_COLORS, TYPE_ICON_PATHS } from '../../types/c4' +import { C4NodeRFData, NODE_COLORS, TYPE_ICON_PATHS } from '@radical/common/c4' import { useDiagramStore } from '../../store/diagramStore' -import { composeEarsSentence, resolveEarsSubject } from '../../types/metamodel' -import { wireframeDataUri } from '../../ai/mockupWireframe' +import { composeEarsSentence, resolveEarsSubject } from '@radical/common/metamodel' +import { wireframeDataUri } from '@radical/common/wireframe' // ─── Diff highlight overlay ──────────────────────────────────────────────── function DiffOverlay({ c4id }: { c4id: string }) { diff --git a/packages/ui/src/components/wireframeGeneration.ts b/packages/ui/src/components/wireframeGeneration.ts new file mode 100644 index 00000000..d6c98ab6 --- /dev/null +++ b/packages/ui/src/components/wireframeGeneration.ts @@ -0,0 +1,28 @@ +// ─── Wireframe generation hook ─────────────────────────────────────────────── +// +// MockupWireframe offers "Generate wireframe" only when the app registers a +// generator. Studio registers its AI-backed one (ai/wireframeGenerator.ts); +// the read-only Hub viewer registers none. + +import type { C4Node, C4Relation } from '@radical/common/c4' + +export interface WireframeGenerator { + /** Whether generation is currently possible (e.g. AI is configured). */ + enabled(): boolean + generate( + mockupId: string, + nodes: Record, + relations: Record, + signal: AbortSignal, + ): Promise<{ svg: string; usage?: { inputTokens: number; outputTokens: number } }> +} + +let generator: WireframeGenerator | null = null + +export function setWireframeGenerator(next: WireframeGenerator | null): void { + generator = next +} + +export function wireframeGenerator(): WireframeGenerator | null { + return generator +} diff --git a/src/renderer/src/hooks/useOutsideClick.ts b/packages/ui/src/hooks/useOutsideClick.ts similarity index 100% rename from src/renderer/src/hooks/useOutsideClick.ts rename to packages/ui/src/hooks/useOutsideClick.ts diff --git a/src/renderer/src/hub/conceptViews.ts b/packages/ui/src/hub/conceptViews.ts similarity index 98% rename from src/renderer/src/hub/conceptViews.ts rename to packages/ui/src/hub/conceptViews.ts index 279a597d..189bf87f 100644 --- a/src/renderer/src/hub/conceptViews.ts +++ b/packages/ui/src/hub/conceptViews.ts @@ -16,7 +16,7 @@ // them on the viewport, so it drops them and the view keeps the imported // layout. -import type { DiagramView, NodePosition } from '../types/c4' +import type { DiagramView, NodePosition } from '@radical/common/c4' type Raw = Record type IdMap = (id: string) => string | undefined diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css new file mode 100644 index 00000000..b33751c4 --- /dev/null +++ b/packages/ui/src/index.css @@ -0,0 +1,2928 @@ +/* ─── Reset & Base ─────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + /* C4 palette */ + --c4-person: #08427b; + --c4-system: #1168bd; + --c4-ext-system: #6b6b6b; + --c4-container: #438dd5; + --c4-component: #85bbf0; + --c4-component-fg:#000; + + /* UI chrome — dimmed graphite, not near-black: the C4 palette is dark + (person #08427b, system #1168bd) and sank into a #0f0f1c canvas */ + --bg-app: #262b36; + --bg-panel: #2d3340; + --bg-toolbar: rgba(45, 51, 64, 0.85); + --bg-canvas: #22262f; + --border-color: #3e4555; + --border-color-strong: #4f5769; + --text-primary: #e6e9f2; + --text-secondary: #c3c8d6; + --text-muted: #a0a7ba; + --accent: #6ea8ff; + --accent-rgb: 110,168,255; + --accent-soft: rgba(110,168,255,0.12); + --danger: #ff7373; + --success: #6bffb8; + --warning: #ffc36b; + --canvas-dots: rgba(255,255,255,0.10); + --minimap-mask: rgba(34,38,47,0.7); + --hint-bg: rgba(34,38,47,0.9); + --hover-bg: rgba(255,255,255,0.08); + --hover-bg-subtle: rgba(255,255,255,0.05); + --hover-bg-faint: rgba(255,255,255,0.035); + --input-bg: rgba(255,255,255,0.06); + --handle-bg: rgba(255,255,255,0.4); + --handle-border:rgba(255,255,255,0.6); + --edge-label-bg:rgba(34,38,47,0.9); + --edge-label-border:rgba(148,163,184,0.3); + + /* Design system tokens */ + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 10px; + --radius-xl: 14px; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.18); + --shadow-md: 0 4px 12px rgba(0,0,0,0.28), 0 1px 3px rgba(0,0,0,0.18); + --shadow-lg: 0 12px 32px rgba(0,0,0,0.40), 0 2px 8px rgba(0,0,0,0.22); + --shadow-glow: 0 0 0 3px rgba(var(--accent-rgb), 0.20); + --ease-out: cubic-bezier(0.4, 0, 0.2, 1); + --ease-spring: cubic-bezier(0.34, 1.30, 0.64, 1); + + font-family: 'Inter', 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + font-size: 13px; + font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11', 'ss01'; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + color: var(--text-primary); +} + +/* ─── Light theme ──────────────────────────────────────────────────── */ +[data-theme="light"] { + --bg-app: #f4f5f9; + --bg-panel: #ffffff; + --bg-toolbar: rgba(248, 249, 252, 0.78); + --bg-canvas: #f2f3f7; + --border-color: #e1e3eb; + --border-color-strong: #c8cbd6; + --text-primary: #1a1a2e; + --text-secondary: #44485a; + --text-muted: #6b6b80; + --accent: #3b6fe6; + --accent-rgb: 59,111,230; + --accent-soft: rgba(59,111,230,0.10); + --danger: #dc2626; + --success: #16a34a; + --warning: #d97706; + --canvas-dots: rgba(0,0,0,0.07); + --minimap-mask: rgba(244,245,249,0.7); + --hint-bg: rgba(244,245,249,0.92); + --hover-bg: rgba(0,0,0,0.05); + --hover-bg-subtle: rgba(0,0,0,0.035); + --hover-bg-faint: rgba(0,0,0,0.022); + --input-bg: rgba(0,0,0,0.035); + --handle-bg: rgba(0,0,0,0.3); + --handle-border:rgba(0,0,0,0.4); + --edge-label-bg:rgba(255,255,255,0.92); + --edge-label-border:rgba(0,0,0,0.12); + --shadow-sm: 0 1px 2px rgba(20,30,60,0.06); + --shadow-md: 0 4px 12px rgba(20,30,60,0.10), 0 1px 3px rgba(20,30,60,0.06); + --shadow-lg: 0 12px 32px rgba(20,30,60,0.16), 0 2px 8px rgba(20,30,60,0.08); +} + +body { + background: + radial-gradient(ellipse 80% 50% at 50% -10%, rgba(var(--accent-rgb), 0.07), transparent 60%), + var(--bg-app); + height: 100vh; + overflow: hidden; + user-select: none; +} + +/* ─── Modern scrollbars ───────────────────────────────────────────── */ +*::-webkit-scrollbar { width: 10px; height: 10px; } +*::-webkit-scrollbar-track { background: transparent; } +*::-webkit-scrollbar-thumb { + background: rgba(127, 127, 160, 0.22); + border-radius: 10px; + border: 2px solid transparent; + background-clip: padding-box; + transition: background 0.2s ease; +} +*::-webkit-scrollbar-thumb:hover { background: rgba(127, 127, 160, 0.40); background-clip: padding-box; border: 2px solid transparent; } + +/* ─── Global focus ring ───────────────────────────────────────────── */ +:focus-visible { + outline: none; + box-shadow: var(--shadow-glow); + border-radius: var(--radius-sm); +} + +/* Collapsed panel rails */ +.app-layout.rp-collapsed { grid-template-columns: 220px 1fr 18px; } +.app-layout.lp-collapsed.rp-collapsed { grid-template-columns: 18px 1fr 18px; } + +/* Presentation mode — same 3-column layout as modelling (left panel + canvas + right panel) */ +.app-layout.mode-presentation.rp-collapsed { grid-template-columns: 240px 1fr 18px; } +.app-layout.mode-presentation.lp-collapsed.rp-collapsed { grid-template-columns: 18px 1fr 18px; } + +/* ─── Time Travel Bar ─────────────────────────────────────────────── */ +.slidestrip-play-btn.active { + color: var(--accent); +} +.slidestrip-card.active { + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + box-shadow: inset 0 -2px 0 var(--accent); +} + +/* ─── Toolbar ────────────────────────────────────────────────────────── */ +.toolbar { + grid-area: toolbar; + background: var(--bg-toolbar); + backdrop-filter: blur(18px) saturate(160%); + -webkit-backdrop-filter: blur(18px) saturate(160%); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + position: relative; + z-index: 10; + -webkit-app-region: drag; +} +.toolbar::after { + content: ''; + position: absolute; left: 0; right: 0; bottom: -1px; height: 1px; + background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.18), transparent); + pointer-events: none; +} +.toolbar > * { -webkit-app-region: no-drag; } + +/* ─── App brand (logo + menu trigger combo) ─────────────────────────── */ +.app-brand:hover, +.app-brand.open { + background: var(--hover-bg-subtle); + border-color: var(--border-color); +} +.app-brand-caret.open { transform: rotate(180deg); color: var(--text-primary); } + +.toolbar-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: var(--hover-bg-faint); + color: var(--text-primary); + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: background 0.15s var(--ease-out), border-color 0.15s var(--ease-out), color 0.15s var(--ease-out), transform 0.1s var(--ease-out); + white-space: nowrap; +} +.toolbar-btn:hover { background: var(--hover-bg); border-color: var(--border-color-strong); } +.toolbar-btn:active { transform: translateY(1px); } +.toolbar-btn.active { + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 92%, white), var(--accent)); + color: #fff; border-color: transparent; + box-shadow: 0 1px 0 rgba(255,255,255,0.18) inset, var(--shadow-sm); +} +.toolbar-btn.danger:hover { border-color: var(--danger); color: var(--danger); } +.toolbar-btn:disabled { opacity: 0.35; pointer-events: none; } + +/* Smart Layout — subtle accent only on hover so the button doesn't look "always active". */ +.toolbar-btn-accent .toolbar-btn-accent-icon { color: var(--accent); } +.toolbar-btn-accent:hover { + background: color-mix(in srgb, var(--accent) 14%, transparent); + border-color: color-mix(in srgb, var(--accent) 55%, transparent); +} + +.toolbar-sep { width: 1px; height: 22px; background: var(--border-color); margin: 0 4px; opacity: 0.7; } + +.toolbar-btn svg { width: 14px; height: 14px; } + +/* Smart Layout — in-run progress state. Overrides the generic :disabled dim + so the live "trying N algorithms…" label stays legible while it runs. */ +.toolbar-btn-busy:disabled { + opacity: 1; + background: color-mix(in srgb, var(--accent) 14%, transparent); + border-color: color-mix(in srgb, var(--accent) 45%, transparent); + color: var(--text-primary); +} +.toolbar-btn-spin { + display: inline-flex; + animation: toolbar-btn-spin 1.1s linear infinite; +} +@keyframes toolbar-btn-spin { + to { transform: rotate(360deg); } +} + +.toolbar-btn-icon-only { padding: 0 7px; } + +/* "Why this layout?" report popover */ +.smart-layout-report-wrap { position: relative; display: inline-flex; } +.smart-layout-report-popover { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 1000; + width: 420px; + max-width: min(420px, calc(100vw - 32px)); + background: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: 10px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15), 0 8px 24px rgba(0, 0, 0, 0.28); + padding: 10px; + animation: app-menu-in 120ms ease-out; + transform-origin: top left; +} +.smart-layout-report-header { padding: 2px 4px 8px; } +.smart-layout-report-title { font-size: 12.5px; font-weight: 700; margin-bottom: 4px; } +.smart-layout-report-sub { font-size: 11px; color: var(--text-secondary); line-height: 1.5; } +.smart-layout-report-table-wrap { max-height: 260px; overflow-y: auto; overflow-x: auto; } +.smart-layout-report-table { width: 100%; border-collapse: collapse; font-size: 11px; } +.smart-layout-report-table th { + text-align: left; + font-weight: 600; + color: var(--text-muted); + padding: 4px 6px; + border-bottom: 1px solid var(--border-color); + white-space: nowrap; +} +.smart-layout-report-table td { + padding: 4px 6px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent); + white-space: nowrap; +} +.smart-layout-report-table tr.winner { color: var(--accent); font-weight: 600; } + +/* ── Mode switcher ────────────────────────────────────────────────── */ +.toolbar-mode-switch { + display: flex; + align-items: center; + background: rgba(0,0,0,0.18); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 3px; + gap: 2px; + box-shadow: inset 0 1px 2px rgba(0,0,0,0.20); +} +.toolbar-mode-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: background 0.18s var(--ease-out), color 0.18s var(--ease-out); + white-space: nowrap; +} +.toolbar-mode-btn svg { width: 14px; height: 14px; } +.toolbar-mode-btn:hover { color: var(--text-primary); background: var(--hover-bg); } +.toolbar-mode-btn.active { + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 92%, white), var(--accent)); + color: #fff; + box-shadow: 0 1px 0 rgba(255,255,255,0.18) inset, var(--shadow-sm); +} + +/* ── Toolbar dropdown (snapshots) ─────────────────────────────────── */ +.toolbar-dropdown-item.create { + color: var(--accent); + font-weight: 600; + border-bottom: 1px solid var(--border-color); + margin-bottom: 2px; +} +.snapshot-action.restore:hover { + color: var(--accent); +} +.snapshot-action.delete:hover { + color: var(--danger); +} + +/* ─── App menu (hamburger popover) ──────────────────────────────────── */ +@keyframes app-menu-in { + from { opacity: 0; transform: translateY(-4px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.app-menu-item.danger { color: var(--text-secondary); } +.app-menu-item.danger:hover { background: rgba(255, 107, 107, 0.12); color: #ff6b6b; } +.app-menu-item.danger:hover .app-menu-icon { color: #ff6b6b; } + +/* ─── View Bar ───────────────────────────────────────────────────────── */ +.view-bar { + grid-area: viewbar; + display: flex; + align-items: center; + gap: 2px; + padding: 0 8px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border-color); + overflow-x: auto; + overflow-y: visible; + min-height: 30px; +} + +.view-tab { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + white-space: nowrap; + transition: background 0.1s, color 0.1s; +} +.view-tab:hover { + background: var(--hover-bg); + color: var(--text-primary); +} +.view-tab.active { + background: var(--accent); + color: #000; + border-color: var(--accent); + font-weight: 600; +} + +.view-tab-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border: none; + background: transparent; + color: inherit; + font-size: 12px; + line-height: 1; + cursor: pointer; + border-radius: 2px; + opacity: 0.5; + padding: 0; +} +.view-tab-close:hover { + opacity: 1; + background: rgba(255,107,107,0.3); +} + +.view-tab-add { + font-size: 14px; + font-weight: 700; + padding: 2px 8px; +} + +.view-tab-input { + background: var(--input-bg); + border: 1px solid var(--accent); + border-radius: 3px; + color: var(--text-primary); + font-size: 11px; + padding: 1px 4px; + width: 80px; + outline: none; +} + +/* ─── Sidebar ────────────────────────────────────────────────────────── */ +.sidebar { + grid-area: sidebar; + background: var(--bg-panel); + border-right: 1px solid var(--border-color); + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + gap: 0; + position: relative; + min-width: 0; +} + +.palette-group-label { + font-size: 9px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--text-muted); + padding: 8px 10px 2px; + opacity: .6; +} + +.palette-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 6px; + cursor: grab; + transition: background 0.15s; + margin-bottom: 4px; + border: 1px solid transparent; + user-select: none; +} +.palette-item:hover { background: var(--hover-bg-subtle); border-color: var(--border-color); } +.palette-item:active { cursor: grabbing; } + +.palette-badge { + width: 28px; height: 24px; + border-radius: 5px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.palette-label { font-size: 12px; } +.palette-sublabel { font-size: 10px; color: var(--text-muted); } + +.tree-node { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + cursor: grab; + border-radius: 0; + font-size: 12px; + transition: background 0.1s; +} +.tree-node:active { cursor: grabbing; } +.tree-node:hover { background: var(--hover-bg-faint); } +.tree-node.selected { background: rgba(92,155,255,0.15); color: var(--accent); } + +.tree-toggle { width: 14px; text-align: center; color: var(--text-muted); font-size: 10px; } + +.tree-badge { + display: flex; + align-items: center; + flex-shrink: 0; +} + +/* Type icons in the node tree and filter chips. The C4 palette is dark (the + person navy all but vanishes on a dark panel), so lift it toward white in + the dark theme. Only here: node fills on the canvas are model data. */ +.type-icon { fill: color-mix(in srgb, var(--type-color) 62%, white); } +[data-theme="light"] .type-icon { fill: var(--type-color); } + +.tree-view-toggle { + flex-shrink: 0; + cursor: pointer; + font-size: 10px; + opacity: 0.5; + margin-left: auto; + padding: 0 2px; +} +.tree-view-toggle:hover { opacity: 1; } + +.tree-node.filter-match .tree-node-label { color: var(--accent); font-weight: 600; } + +/* ─── Right panel · nodes filter bar (search + type chips + bulk actions) ── */ +.rp-filter-bar { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 12px 8px; + border-bottom: 1px solid var(--border-color); + margin-bottom: 2px; +} +.rp-filter-search { position: relative; display: flex; align-items: center; } +.rp-filter-search .props-input { padding: 5px 26px; font-size: 12px; } +.rp-filter-search-icon { + position: absolute; + left: 8px; + display: flex; + color: var(--text-muted); + pointer-events: none; +} +.rp-filter-search-clear { + position: absolute; + right: 6px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 2px; + border-radius: 3px; +} +.rp-filter-search-clear:hover { color: var(--text-primary); background: var(--hover-bg-faint); } + +.rp-filter-chips { display: flex; flex-wrap: wrap; gap: 4px; } +.rp-filter-chip { + display: inline-flex; + align-items: center; + gap: 4px; + height: 20px; + padding: 0 7px; + border-radius: 10px; + font-size: 10.5px; + font-weight: 500; + background: color-mix(in srgb, var(--text-primary) 6%, transparent); + color: var(--text-muted); + border: 1px solid var(--border-color); + cursor: pointer; + transition: background-color 0.12s, color 0.12s, border-color 0.12s; +} +.rp-filter-chip:hover { border-color: var(--border-color-strong); color: var(--text-primary); } +.rp-filter-chip.active { + background: color-mix(in srgb, var(--accent) 18%, transparent); + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 40%, transparent); +} +.rp-filter-chip-count { opacity: 0.65; font-variant-numeric: tabular-nums; } + +.rp-filter-actions { display: flex; align-items: center; gap: 6px; } +.rp-filter-summary { font-size: 10.5px; color: var(--text-muted); margin-right: auto; } +.rp-filter-action-btn { + font-size: 10.5px; + font-weight: 500; + padding: 3px 8px; + border-radius: 4px; + border: 1px solid var(--border-color); + background: transparent; + color: var(--text-primary); + cursor: pointer; +} +.rp-filter-action-btn:hover { border-color: var(--accent); color: var(--accent); } +.rp-filter-action-btn:disabled { opacity: 0.4; cursor: default; } +.rp-filter-action-btn:disabled:hover { border-color: var(--border-color); color: var(--text-primary); } +.rp-filter-action-btn.subtle { border-color: transparent; color: var(--text-muted); padding: 3px 4px; } +.rp-filter-action-btn.subtle:hover { color: var(--accent); } + +/* ─── Right panel (horizontal slider: tree ↔ props) ─────────────────── */ +.right-panel { + grid-area: rightpanel; + background: + linear-gradient(180deg, color-mix(in srgb, var(--bg-panel) 100%, transparent) 0%, color-mix(in srgb, var(--bg-panel) 96%, transparent) 100%); + border-left: 1px solid var(--border-color); + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: visible; + position: relative; +} +.right-panel .panel-content { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; + transition: opacity 0.18s ease; +} +.right-panel.collapsed .panel-content { + opacity: 0; + pointer-events: none; +} + +.rp-slider { + width: 200%; + height: 100%; + display: flex; + flex-direction: row; + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} +.rp-slider.show-props { + transform: translateX(-50%); +} + +.rp-pane { + width: 50%; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +.rp-back-btn { + flex-shrink: 0; + width: 100%; + height: 32px; + border: none; + border-bottom: 1px solid var(--border-color); + background: var(--bg-toolbar); + color: var(--text-muted); + cursor: pointer; + font-size: 11px; + display: flex; + align-items: center; + padding: 0 12px; + gap: 6px; + position: sticky; + top: 0; + z-index: 2; + transition: color 0.15s, background 0.15s; +} +.rp-back-btn:hover { + color: var(--text-primary); + background: var(--hover-bg); +} + +/* ─── Left panel (palette + tree) ───────────────────────────────────── */ +.left-panel { + grid-area: leftpanel; + background: + linear-gradient(180deg, color-mix(in srgb, var(--bg-panel) 100%, transparent) 0%, color-mix(in srgb, var(--bg-panel) 96%, transparent) 100%); + border-right: 1px solid var(--border-color); + overflow: visible; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + position: relative; +} +.left-panel .panel-content { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; + transition: opacity 0.18s ease; +} +.left-panel.collapsed .panel-content { + opacity: 0; + pointer-events: none; +} + +/* ─── Panel collapse toggle (chevron tab on inner edge) ─────────────── */ +.panel-toggle { + position: absolute; + top: 14px; + z-index: 5; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 36px; + background: var(--bg-panel); + border: 1px solid var(--border-color); + color: var(--text-muted); + cursor: pointer; + padding: 0; + box-shadow: var(--shadow-sm); + transition: background 0.15s var(--ease-out), color 0.15s var(--ease-out), border-color 0.15s var(--ease-out), transform 0.18s var(--ease-spring); +} +.panel-toggle:hover { + background: var(--hover-bg); + color: var(--accent); + border-color: var(--accent); + transform: scale(1.06); +} +.panel-toggle-left { + right: -10px; + border-radius: 0 6px 6px 0; + border-left: none; +} +.panel-toggle-right { + left: -10px; + border-radius: 6px 0 0 6px; + border-right: none; +} +.left-panel.collapsed .panel-toggle-left, +.right-panel.collapsed .panel-toggle-right { + /* keep the tab visible inside the collapsed rail */ + top: 14px; +} + +/* Two-column layout inside left panel (presentation mode) */ +.left-panel.lp-two-col { + flex-direction: row; + overflow: hidden; +} + +/* ── Left panel accordion ─────────────────────────────────────────── */ +.lp-section { border-bottom: 1px solid var(--border-color); } +.lp-section:last-child { border-bottom: none; } +.lp-section-header { + width: 100%; display: flex; align-items: center; justify-content: space-between; + padding: 10px 14px; background: none; border: none; cursor: pointer; + color: var(--text-secondary); font-size: 10px; font-weight: 700; + letter-spacing: 0.10em; text-transform: uppercase; + user-select: none; + transition: color 0.15s var(--ease-out), background 0.15s var(--ease-out); +} +.lp-section-header:hover { background: var(--hover-bg-faint); color: var(--text-primary); } +.lp-section-title { display: flex; align-items: center; gap: 8px; } +.lp-section-chevron { + color: var(--text-muted); + transition: transform 0.22s var(--ease-spring); + transform: rotate(0deg); + flex-shrink: 0; +} +.lp-section-chevron.open { transform: rotate(90deg); } +.lp-section-count { + display: inline-flex; align-items: center; justify-content: center; + min-width: 18px; height: 17px; padding: 0 6px; + font-size: 9.5px; font-weight: 600; letter-spacing: 0; + border-radius: 9px; + background: var(--accent-soft); + color: var(--accent); + text-transform: none; +} +.lp-section-body { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.22s ease; +} +.lp-section-body.open { grid-template-rows: 1fr; } +.lp-section-body > div { overflow: hidden; min-height: 0; } +.lp-section-body-pad { padding: 4px 8px 12px; } + +/* ── Modern card primitives (snapshots + views) ───────────────────── */ +.lp-card { + position: relative; + display: flex; align-items: center; gap: 8px; + padding: 7px 8px; + background: var(--hover-bg-faint); + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; + font-size: 12px; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.12s ease; +} +.lp-card:hover { + background: var(--hover-bg-subtle); + border-color: var(--border-color); +} +.lp-card:hover .lp-card-actions { opacity: 1; pointer-events: auto; } +.lp-card:focus-within .lp-card-actions { opacity: 1; pointer-events: auto; } +.lp-card.active { + background: rgba(var(--accent-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.35); +} +.lp-card.active::before { + content: ''; position: absolute; left: -1px; top: 8px; bottom: 8px; + width: 3px; border-radius: 2px; + background: var(--accent); +} +.lp-card-body { + flex: 1; min-width: 0; + display: flex; flex-direction: column; gap: 2px; + overflow: hidden; +} +.lp-card-title { + color: var(--text-primary); font-size: 12px; font-weight: 500; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + line-height: 1.3; + min-width: 0; +} +.lp-card.active .lp-card-title { color: var(--text-primary); } +.lp-card-meta { + display: flex; align-items: center; gap: 5px; + color: var(--text-muted); font-size: 10.5px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + min-width: 0; +} +.lp-card-meta > span { white-space: nowrap; } +.lp-meta-dot { opacity: 0.6; flex-shrink: 0; } +/* Actions slot: part of the flex row, collapses to 0-width when hidden. + Expands on hover/focus/active — card body shrinks naturally, no overlap. */ +.lp-card-actions { + display: flex; gap: 2px; align-items: center; flex-shrink: 0; + max-width: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; + transition: max-width 0.18s ease, opacity 0.15s ease; +} +.lp-card:hover .lp-card-actions, +.lp-card:focus-within .lp-card-actions { + max-width: 80px; + opacity: 1; + pointer-events: auto; +} +.lp-card.active .lp-card-actions { + max-width: 80px; + opacity: 1; + pointer-events: auto; +} + +/* Step items inside sequence detail */ +.seq-step { + overflow: hidden; + min-width: 0; +} +.seq-step-actions { + display: flex; gap: 1px; align-items: center; flex-shrink: 0; + max-width: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; + transition: max-width 0.18s ease, opacity 0.15s ease; +} +.seq-step:hover .seq-step-actions, +.seq-step:focus-within .seq-step-actions { + max-width: 64px; + opacity: 1; + pointer-events: auto; +} + +.lp-icon-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 22px; height: 22px; + background: none; border: none; cursor: pointer; + border-radius: 6px; + color: var(--text-muted); + transition: background 0.12s ease, color 0.12s ease; +} +.lp-icon-btn:hover { background: var(--hover-bg); color: var(--text-primary); } +.lp-icon-btn.danger:hover { background: rgba(255,107,107,0.15); color: #ff6b6b; } +.lp-icon-btn.primary { + background: var(--accent); color: #fff; +} +.lp-icon-btn.primary:hover { filter: brightness(1.1); color: #fff; } + +.lp-add-btn { + display: flex; align-items: center; justify-content: center; gap: 6px; + width: 100%; padding: 7px 10px; + background: none; + border: 1px dashed var(--border-color); + border-radius: 8px; + cursor: pointer; + color: var(--text-muted); font-size: 11.5px; font-weight: 500; + transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease; +} +.lp-add-btn:hover { + border-color: rgba(var(--accent-rgb), 0.5); + color: var(--accent); + background: rgba(var(--accent-rgb), 0.06); +} +.lp-add-btn svg { flex-shrink: 0; } + +.lp-inline-form { + display: flex; align-items: center; gap: 4px; + padding: 2px; + background: var(--bg-canvas); + border: 1px solid rgba(var(--accent-rgb), 0.5); + border-radius: 8px; +} +.lp-inline-input { + flex: 1; min-width: 0; + background: none; border: none; outline: none; + color: var(--text-primary); font-size: 12px; + padding: 5px 8px; +} +.lp-inline-input::placeholder { color: var(--text-muted); } + +.lp-empty-state { + display: flex; flex-direction: column; align-items: center; gap: 8px; + padding: 20px 8px 12px; + color: var(--text-muted); font-size: 11px; + text-align: center; +} +.lp-empty-icon { + display: inline-flex; align-items: center; justify-content: center; + width: 32px; height: 32px; + border-radius: 50%; + background: var(--hover-bg-faint); + color: var(--text-muted); + opacity: 0.7; +} + +/* ── Snapshot list ───────────────────────────────────────────────── */ +.lp-snap-list { display: flex; flex-direction: column; gap: 4px; } +.lp-snap-card { padding-left: 8px; cursor: pointer; } +.lp-snap-card:hover { background: var(--bg-hover, rgba(255,255,255,0.04)); } +.lp-snap-badge { + display: inline-flex; align-items: center; justify-content: center; + min-width: 26px; height: 26px; padding: 0 5px; + border-radius: 6px; + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.18), rgba(var(--accent-rgb), 0.08)); + color: var(--accent); + font-size: 10px; font-weight: 700; + letter-spacing: 0.02em; + flex-shrink: 0; + border: 1px solid rgba(var(--accent-rgb), 0.2); +} +.lp-card.active .lp-snap-badge { + background: var(--accent); color: #fff; border-color: var(--accent); +} + +/* ── View list ───────────────────────────────────────────────────── */ +.lp-view-list { display: flex; flex-direction: column; gap: 4px; } +.lp-view-icon { + display: inline-flex; align-items: center; justify-content: center; + width: 26px; height: 26px; + border-radius: 6px; + background: var(--hover-bg-faint); + color: var(--text-muted); + flex-shrink: 0; + transition: background 0.15s ease, color 0.15s ease; +} +.lp-card.active .lp-view-icon { + background: rgba(var(--accent-rgb), 0.18); + color: var(--accent); +} + +/* ─── Canvas ─────────────────────────────────────────────────────────── */ +.canvas-area { + grid-area: canvas; + position: relative; + background: var(--bg-canvas); + overflow: hidden; + min-height: 0; +} + +.dynamic-view-chip.accent { + border-color: rgba(var(--accent-rgb), 0.38); + color: var(--accent); + background: rgba(var(--accent-rgb), 0.10); +} + +.dynamic-view-chip.danger { + border-color: rgba(255,115,115,0.35); + color: #ffb0b0; + background: rgba(255,115,115,0.10); +} + +.dynamic-view-chip.warning { + border-color: rgba(255,195,107,0.35); + color: #ffd18f; + background: rgba(255,195,107,0.10); +} + +.dynamic-view-button.primary { + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.22), rgba(var(--accent-rgb), 0.10)); + border-color: rgba(var(--accent-rgb), 0.45); + color: var(--accent); +} + +.dynamic-view-button.subtle { + width: 100%; + justify-content: center; +} + +.dynamic-view-lifeline.selected { + border-color: rgba(var(--accent-rgb), 0.55); + box-shadow: var(--shadow-glow); +} + +.dynamic-view-lifeline.warning { + border-color: rgba(255,195,107,0.38); +} + +.dynamic-view-lifeline.error { + border-color: rgba(255,115,115,0.42); +} + +.dynamic-view-step-arrow.reverse::after { + right: auto; + left: -1px; + border-right: none; + border-left: 2px solid rgba(var(--accent-rgb), 0.55); + transform: translateY(-50%) rotate(-45deg); +} + +.dynamic-view-step-card.orphan { + width: auto; +} + +.dynamic-view-step-card.orphan .dynamic-view-step-text { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 12px 10px 34px; + border-radius: 12px; + border: 1px solid rgba(255,115,115,0.25); + background: rgba(255,115,115,0.08); + color: var(--text-primary); + font-size: 12px; + font-weight: 600; +} + +.dynamic-view-step-card.selected .dynamic-view-step-bubble, +.dynamic-view-step-self.selected .dynamic-view-step-bubble { + border-color: rgba(var(--accent-rgb), 0.5); + box-shadow: var(--shadow-glow); +} + +.dynamic-view-step-card.warning .dynamic-view-step-bubble, +.dynamic-view-step-self.warning .dynamic-view-step-bubble { + border-color: rgba(255,195,107,0.4); +} + +.dynamic-view-step-card.error .dynamic-view-step-bubble, +.dynamic-view-step-self.error .dynamic-view-step-bubble { + border-color: rgba(255,115,115,0.45); +} + +.dynamic-view-step-list-item.selected { + border-color: rgba(var(--accent-rgb), 0.5); + background: rgba(var(--accent-rgb), 0.10); +} + +.dynamic-view-step-list-item.warning { + border-color: rgba(255,195,107,0.32); +} + +.dynamic-view-step-list-item.error { + border-color: rgba(255,115,115,0.35); +} + +.dynamic-view-validation-item.error { + border-color: rgba(255,115,115,0.35); + background: rgba(255,115,115,0.08); +} + +.dynamic-view-validation-item.warning { + border-color: rgba(255,195,107,0.32); + background: rgba(255,195,107,0.08); +} + +.dynamic-view-empty-side.ok { + color: var(--success); +} + +/* ─── Properties panel ────────────────────────────────────────────── */ + +.props-content { + padding: 0 12px 12px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.props-empty { + color: var(--text-muted); + font-size: 12px; + text-align: center; + margin-top: 40px; +} + +.props-section-title { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 8px; +} + +.props-field { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; +} + +.props-label { + font-size: 11px; + color: var(--text-muted); +} + +.props-input, .props-textarea { + background: var(--input-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + font-size: 12px; + padding: 6px 8px; + width: 100%; + outline: none; + font-family: inherit; + transition: border-color 0.15s; +} +.props-input:focus, .props-textarea:focus { border-color: var(--accent); } +.props-input[readonly], .props-textarea[readonly] { + opacity: 0.65; + cursor: default; +} +.props-input:disabled, .props-textarea:disabled { + opacity: 0.65; + cursor: default; +} + +.props-textarea { resize: vertical; min-height: 60px; max-height: 80vh; overflow-y: auto; } + +.props-type-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; +} + +.props-delete { + margin-top: auto; + padding: 7px; + border: 1px solid var(--danger); + border-radius: 5px; + background: transparent; + color: var(--danger); + cursor: pointer; + font-size: 12px; + transition: background 0.15s; +} +.props-delete:hover { background: rgba(255,107,107,0.12); } + +.mockup-wf { margin-bottom: 12px; } +.mockup-wf-preview { + display: block; + width: 100%; + aspect-ratio: 4 / 3; + object-fit: contain; + background: #fff; + border: 1px solid var(--border-color); + border-radius: 4px; +} +.mockup-wf-empty { + font-size: 11px; + color: var(--text-muted); + font-style: italic; + padding: 10px; + border: 1px dashed var(--border-color); + border-radius: 4px; +} +.mockup-wf-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.mockup-wf-btn { + padding: 5px 9px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--input-bg); + color: var(--text-primary); + font-size: 11px; + cursor: pointer; +} +.mockup-wf-btn:hover { border-color: var(--accent); } +.mockup-wf-status { font-size: 11px; margin-top: 6px; color: var(--text-muted); } +.mockup-wf-status--error { color: var(--danger); } + +/* Wiki page: wider preview, capped so a 4:3 frame doesn't dominate the page */ +.wiki-prose-section.mockup-wf .mockup-wf-preview { max-width: 640px; } +.wiki-prose-section.mockup-wf .mockup-wf-empty { font-size: 13px; max-width: 640px; } + +/* Wiki overview card thumbnail */ +.wiki-card-thumb { + display: block; + width: 100%; + max-width: 240px; + aspect-ratio: 4 / 3; + object-fit: contain; + background: #fff; + border: 1px solid var(--border-color); + border-radius: 3px; + margin-top: 6px; +} + +/* ─── Wiki view ───────────────────────────────────────────────────── */ +.wiki-view { + grid-area: canvas; + height: 100%; + display: flex; + min-height: 0; + overflow: hidden; + background: var(--bg-canvas); +} +.wiki-empty { + margin: auto; + color: var(--text-muted); + font-size: 13px; +} + +/* Left contents rail — height is explicit (not just align-items: stretch) + * because .wiki-nav is itself a column flex container: when its own cross + * size is left implicit, browsers can size it to its content instead of + * stretching it to match .wiki-view, which silently breaks the inner + * .wiki-nav-tree's overflow scroll (it never gets a bounded height to + * scroll within, so long lists overflow the popup instead of scrolling). */ +.wiki-nav { + width: 248px; + flex: 0 0 248px; + height: 100%; + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + min-height: 0; + background: var(--bg-panel); +} +.wiki-nav-head { + padding: 12px 14px 6px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} +.wiki-nav-search { + margin: 0 10px 8px; + padding: 6px 9px; + font-size: 12px; + background: var(--input-bg); + color: inherit; + border: 1px solid var(--border-color); + border-radius: 6px; +} +.wiki-nav-search:focus { border-color: var(--accent); outline: none; } +.wiki-nav-tree { + flex: 1; + overflow-y: auto; + padding-bottom: 10px; +} +.wiki-nav-section + .wiki-nav-section { margin-top: 4px; } +.wiki-nav-section-head { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + text-align: left; + padding: 8px 10px 4px; + border: none; + background: none; + cursor: pointer; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--text-muted); +} +.wiki-nav-section-head:hover { color: var(--text-secondary); } +.wiki-nav-section-chevron { transition: transform 0.15s; font-size: 9px; } +.wiki-nav-section-chevron.collapsed { transform: rotate(-90deg); } +.wiki-nav-section-count { + margin-left: auto; + padding: 0 5px; + border-radius: 999px; + background: rgba(255,255,255,0.06); + color: var(--text-muted); + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0; + text-transform: none; +} +.wiki-nav-subsection-head { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + text-align: left; + padding: 5px 10px 3px 23px; + border: none; + background: none; + cursor: pointer; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.03em; + color: var(--text-muted); + opacity: 0.85; +} +.wiki-nav-subsection-head:hover { color: var(--text-secondary); opacity: 1; } +.wiki-nav-subsection + .wiki-nav-subsection { margin-top: 2px; } +.wiki-nav-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + padding: 6px 10px; + font-size: 13px; + background: transparent; + color: var(--text-secondary); + border: none; + border-left: 2px solid transparent; + cursor: pointer; +} +.wiki-nav-home { margin-bottom: 2px; font-weight: 600; color: var(--text-primary); } +.wiki-nav-item:hover { background: var(--hover-bg); color: var(--text-primary); } +.wiki-nav-item.active { + background: var(--accent-soft); + color: var(--accent); + border-left-color: var(--accent); + font-weight: 600; +} +.wiki-nav-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: 0 0 8px; +} +.wiki-nav-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Document column — fills the full canvas between the side panels */ +.wiki-page { + flex: 1; + height: 100%; + min-width: 0; + overflow-y: auto; + background: var(--bg-panel); +} +.wiki-doc { + width: 100%; + max-width: none; + margin: 0; + padding: 34px 48px 72px; +} +@media (min-width: 1500px) { + .wiki-doc { padding: 38px 72px 80px; } +} +@media (max-width: 860px) { + .wiki-doc { padding: 24px 22px 64px; } +} + +/* ── Single page / Multi page toggle ────────────────────────────────────── */ +.wiki-page-mode-toggle { + display: inline-flex; + gap: 2px; + padding: 3px; + margin: 24px 48px 0; + border-radius: 8px; + background: var(--hover-bg, rgba(255,255,255,0.04)); + border: 1px solid var(--border-color); + width: fit-content; +} +@media (min-width: 1500px) { .wiki-page-mode-toggle { margin: 28px 72px 0; } } +@media (max-width: 860px) { .wiki-page-mode-toggle { margin: 16px 22px 0; } } +.wiki-page-mode-toggle button { + border: none; + background: transparent; + color: var(--text-muted); + font-size: 12px; + font-weight: 600; + padding: 5px 12px; + border-radius: 6px; + cursor: pointer; + transition: background 0.1s, color 0.1s; +} +.wiki-page-mode-toggle button:hover { color: var(--text-secondary); } +.wiki-page-mode-toggle button.active { + background: var(--bg-panel); + color: var(--text-primary); + box-shadow: 0 1px 2px rgba(0,0,0,0.15); +} + +/* ── Multi page mode: a child's full page embedded inline ───────────────── */ +.wiki-embedded-children { + display: flex; + flex-direction: column; +} +.wiki-embedded-child { + border-top: 1px dashed var(--border-color); + margin-top: 12px; +} +.wiki-doc-embedded { + padding: 24px 0 4px 28px; + border-left: 3px solid var(--type-color, var(--border-color)); + margin-left: 4px; +} +.wiki-doc-embedded .wiki-doc-title, +.wiki-doc-embedded .wiki-doc-title.wiki-inline-view, +.wiki-doc-embedded .wiki-doc-title.wiki-inline-input { + font-size: 21px; +} +.wiki-embedded-open { + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + white-space: nowrap; +} +.wiki-embedded-open:hover { color: var(--text-secondary); } + +.wiki-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-muted); + margin-bottom: 20px; +} +.wiki-breadcrumb-sep { opacity: 0.5; } +.wiki-breadcrumb-current { color: var(--text-secondary); } + +.wiki-doc-header { + border-bottom: 1px solid var(--border-color); + padding-bottom: 20px; + margin-bottom: 26px; +} +.wiki-kicker { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--type-color, var(--text-muted)); + margin-bottom: 8px; +} +.wiki-kicker-icon { display: inline-flex; } +.wiki-kicker-tag { + margin-left: 6px; + padding: 1px 7px; + border-radius: 9px; + font-size: 9px; + letter-spacing: 0.05em; + background: var(--hover-bg); + color: var(--text-muted); +} +.wiki-doc-title { + font-size: 30px; + line-height: 1.18; + font-weight: 760; + letter-spacing: -0.01em; + color: var(--text-primary); + margin: 0; + display: block; +} +.wiki-doc-sub { + font-size: 13px; + color: var(--text-muted); + margin: 6px 0 0; +} +.wiki-lead { + display: block; + margin-top: 12px; + font-size: 16px; + line-height: 1.6; + color: var(--text-secondary); +} + +/* EARS sentence blockquote */ +.wiki-ears-sentence { margin-top: 16px; } +.ears-sentence { + margin: 0; + padding: 12px 16px; + border-left: 4px solid #0e7490; + background: color-mix(in srgb, #0e7490 8%, var(--bg-panel)); + border-radius: 0 4px 4px 0; + font-size: 15px; + font-style: normal; + line-height: 1.6; + color: var(--text-primary); +} +.ears-editable { cursor: default; } +.ears-fixed { + color: var(--text-muted); + font-style: italic; +} +.ears-slot { + display: inline; + cursor: text; + border-bottom: 1.5px dashed transparent; + transition: border-color 0.15s, background 0.15s; + padding: 0 2px; + border-radius: 2px; +} +.ears-slot:hover { border-bottom-color: #0e7490; background: color-mix(in srgb, #0e7490 12%, transparent); } +.ears-slot-filled { font-weight: 500; color: var(--text-primary); } +.ears-slot-empty { color: var(--text-muted); font-style: italic; border-bottom-color: var(--border-color); } +.ears-subject { font-weight: 600; color: #0e7490; } +.ears-slot-input { + display: inline; + font: inherit; + font-size: 15px; + font-weight: 500; + line-height: 1.4; + background: var(--input-bg); + border: 1.5px solid #0e7490; + border-radius: 3px; + padding: 1px 4px; + color: var(--text-primary); + outline: none; + min-width: 80px; + width: auto; +} + +/* Right panel compact variant */ +.ears-sentence-sm { + padding: 6px 10px; + margin: 6px 0; + background: var(--input-bg); + border-left: 3px solid #0e7490; + border-radius: 0 4px 4px 0; + font-size: 11.5px; + line-height: 1.5; + color: var(--text-primary); +} +.ears-fixed-sm { color: var(--text-muted); font-style: italic; } +.ears-slot-sm { + display: inline; + cursor: text; + padding: 0 1px; + border-bottom: 1px dashed transparent; + border-radius: 2px; +} +.ears-slot-sm:hover { border-bottom-color: #0e7490; background: color-mix(in srgb, #0e7490 12%, transparent); } +.ears-slot-sm.ears-slot-filled { font-weight: 500; } +.ears-subject-sm { font-weight: 600; color: #0e7490; } +.ears-slot-sm.ears-slot-empty { color: var(--text-muted); font-style: italic; border-bottom-color: var(--border-color); } +.ears-slot-input-sm { + font: inherit; + font-size: 11.5px; + font-weight: 500; + background: var(--bg-panel); + border: 1px solid #0e7490; + border-radius: 2px; + padding: 0 3px; + color: var(--text-primary); + outline: none; + min-width: 60px; +} + +/* EARS quick entry: type a plain sentence instead of picking ears_type first */ +.ears-quick-entry { margin: 6px 0 0; } +.ears-quick-entry-input { + width: 100%; + font: inherit; + font-size: 13px; + font-style: italic; + background: var(--input-bg); + border: 1px dashed var(--border-color); + border-radius: 4px; + padding: 6px 8px; + color: var(--text-primary); + outline: none; + box-sizing: border-box; +} +.ears-quick-entry-input:focus { border-color: #0e7490; border-style: solid; } +.ears-quick-entry-input::placeholder { color: var(--text-muted); } +.ears-quick-entry-hint { + display: block; + margin-top: 4px; + font-size: 11px; + color: #0e7490; +} +.ears-quick-entry-sm .ears-quick-entry-input { font-size: 11px; padding: 4px 6px; } +.ears-quick-entry-sm .ears-quick-entry-hint { font-size: 10px; } + +.wiki-main { min-width: 0; } + +.wiki-prose-section { margin-bottom: 28px; } +.wiki-h2 { + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); + margin: 0 0 10px; + display: flex; + align-items: center; + gap: 8px; +} +.wiki-count { + font-size: 11px; + font-weight: 700; + color: var(--text-muted); + background: var(--hover-bg); + border-radius: 9px; + padding: 1px 8px; +} +.wiki-prose { + display: block; + font-size: 14.5px; + line-height: 1.65; + color: var(--text-secondary); + white-space: pre-wrap; +} +.wiki-muted { color: var(--text-muted); font-size: 13px; font-style: italic; margin: 0; } + +/* Cards (children / overview) */ +.wiki-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; +} +.wiki-card { + position: relative; + display: flex; + gap: 0; + text-align: left; + padding: 0; + background: var(--bg-app); + border: 1px solid var(--border-color); + border-radius: 9px; + overflow: hidden; + transition: border-color 0.12s, transform 0.12s; +} +.wiki-card:hover { + border-color: var(--border-color-strong); + transform: translateY(-1px); +} +.wiki-card-open { + display: flex; + flex: 1; + gap: 0; + text-align: left; + padding: 0; + background: transparent; + border: none; + cursor: pointer; + min-width: 0; +} +.wiki-card-del { + position: absolute; + top: 5px; + right: 5px; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 5px; + background: transparent; + color: var(--text-muted); + font-size: 16px; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity 0.12s, background 0.12s, color 0.12s; +} +.wiki-card:hover .wiki-card-del { opacity: 1; } +.wiki-card-del:hover { background: color-mix(in srgb, #dc2626 16%, transparent); color: #dc2626; } +.wiki-card-bar { width: 4px; flex: 0 0 4px; } +.wiki-card-body { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; min-width: 0; } +.wiki-card-title { font-size: 13.5px; font-weight: 650; color: var(--text-primary); } +.wiki-card-meta { font-size: 11px; color: var(--text-muted); } +.wiki-card-desc { + font-size: 12px; + color: var(--text-secondary); + margin-top: 4px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Relations as readable lines */ +.wiki-rel-list { display: flex; flex-direction: column; gap: 4px; } +.wiki-rel { + display: flex; + gap: 10px; + padding: 9px 10px; + border-radius: 8px; +} +.wiki-rel:hover { background: var(--hover-bg-faint); } +.wiki-rel-del { + flex: 0 0 auto; + width: 22px; + height: 22px; + align-self: center; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + font-size: 17px; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity 0.12s, background 0.12s, color 0.12s; +} +.wiki-rel:hover .wiki-rel-del { opacity: 1; } +.wiki-rel-del:hover { background: color-mix(in srgb, #dc2626 16%, transparent); color: #dc2626; } +.wiki-rel-arrow { + font-size: 15px; + line-height: 1.5; + font-weight: 700; + color: var(--text-muted); + flex: 0 0 auto; +} +.wiki-rel-arrow.out { color: var(--accent); } +.wiki-rel-main { min-width: 0; flex: 1; } +.wiki-rel-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.wiki-rel-verb { font-size: 12px; color: var(--text-muted); } +.wiki-rel-target { font-size: 14px; font-weight: 650; } +.wiki-rel-attrs { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 14px; + margin-top: 3px; + font-size: 12.5px; + color: var(--text-secondary); +} +.wiki-rel-label { font-style: italic; color: var(--text-secondary); } +.wiki-rel-attr-key { color: var(--text-muted); font-size: 11px; } +.wiki-rel-pill { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--accent); + background: var(--accent-soft); + border-radius: 8px; + padding: 1px 8px; +} + +/* Section heads with inline add controls */ +.wiki-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +.wiki-section-head .wiki-h2 { margin: 0; } +.wiki-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +/* Add menu (dropdown picker) */ +.wiki-add { position: relative; flex: 0 0 auto; } +.wiki-add-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 11px; + font-size: 12.5px; + font-weight: 600; + color: #fff; + background: var(--accent); + border: 1px solid var(--accent); + border-radius: 7px; + cursor: pointer; + transition: filter 0.12s; +} +.wiki-add-btn:hover { filter: brightness(1.08); } +.wiki-add-btn.ghost { + color: var(--accent); + background: var(--accent-soft); + border-color: transparent; +} +.wiki-add-plus { font-size: 14px; line-height: 1; font-weight: 800; } +.wiki-add-menu { + position: absolute; + top: calc(100% + 5px); + right: 0; + z-index: 30; + min-width: 200px; + max-width: 320px; + background: var(--bg-panel, var(--bg-app)); + border: 1px solid var(--border-color-strong); + border-radius: 9px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22); + padding: 5px; +} +.wiki-add-search { + width: 100%; + box-sizing: border-box; + margin-bottom: 5px; + padding: 6px 8px; + font-size: 12.5px; + color: var(--text-primary); + background: var(--bg-app); + border: 1px solid var(--border-color); + border-radius: 6px; +} +.wiki-add-search:focus { border-color: var(--accent); outline: none; } +.wiki-add-list { max-height: 280px; overflow-y: auto; display: flex; flex-direction: column; } +.wiki-add-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + padding: 7px 9px; + font-size: 13px; + color: var(--text-primary); + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; +} +.wiki-add-item:hover { background: var(--hover-bg); } +.wiki-add-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; } +.wiki-add-item-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wiki-add-item-sub { font-size: 11px; color: var(--text-muted); flex: 0 0 auto; } +.wiki-add-empty { padding: 9px 10px; font-size: 12px; color: var(--text-muted); font-style: italic; } + +/* Destructive action button */ +.wiki-danger-btn { + flex: 0 0 auto; + padding: 6px 12px; + font-size: 12.5px; + font-weight: 600; + color: #dc2626; + background: transparent; + border: 1px solid color-mix(in srgb, #dc2626 45%, transparent); + border-radius: 7px; + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.wiki-danger-btn:hover { background: #dc2626; color: #fff; border-color: #dc2626; } + +/* Meta row — short facts (Parent, Technology, External, …), part of the header */ +.wiki-meta-row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 22px; + margin: 14px 0 0; + padding-top: 14px; + border-top: 1px solid var(--border-color); +} +.wiki-meta-item { + display: flex; + align-items: baseline; + gap: 7px; + margin: 0; +} +.wiki-meta-item dt { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--text-muted); + margin: 0; +} +.wiki-meta-item dd { margin: 0; font-size: 13px; color: var(--text-primary); min-width: 0; } +.wiki-fact-text { display: inline-block; } + +/* Boolean pill */ +.wiki-bool { + font-size: 11px; + font-weight: 700; + border-radius: 9px; + padding: 2px 10px; + border: 1px solid transparent; + cursor: pointer; +} +.wiki-bool.on { background: color-mix(in srgb, #16a34a 18%, transparent); color: #16a34a; } +.wiki-bool.off { background: var(--hover-bg); color: var(--text-muted); } +.wiki-bool:disabled { cursor: default; opacity: 0.7; } + +/* Enum pill */ +.wiki-enum-pill { + display: inline-block; + font-size: 11px; + font-weight: 650; + border-radius: 8px; + padding: 1px 9px; + background: var(--hover-bg); + color: var(--text-secondary); + text-transform: capitalize; +} +.enum-accepted, .enum-active { background: color-mix(in srgb, #16a34a 16%, transparent); color: #16a34a; } +.enum-proposed { background: color-mix(in srgb, #d97706 16%, transparent); color: #d97706; } +.enum-deprecated, .enum-superseded { background: color-mix(in srgb, #dc2626 14%, transparent); color: #dc2626; } +.enum-core { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); } + +/* Links */ +.wiki-link { + background: none; + border: none; + padding: 0; + color: var(--accent); + font: inherit; + cursor: pointer; +} +.wiki-link:hover { text-decoration: underline; } + +/* Inline editors — display first, become inputs on click */ +.wiki-inline-view { + cursor: text; + border-radius: 4px; + transition: background 0.1s, box-shadow 0.1s; + box-decoration-break: clone; +} +.wiki-inline-view.readonly { cursor: default; } +.wiki-inline-view:not(.readonly):hover { + background: var(--hover-bg-faint); + box-shadow: 0 0 0 3px var(--hover-bg-faint); +} +.wiki-inline-view.empty { color: var(--text-muted); font-style: italic; } +.wiki-inline-input { + font: inherit; + color: var(--text-primary); + background: var(--bg-app); + border: 1px solid var(--accent); + border-radius: 5px; + padding: 2px 6px; + box-shadow: var(--shadow-glow); + outline: none; + width: 100%; + box-sizing: border-box; +} +.wiki-inline-textarea { + display: block; + width: 100%; + resize: none; + overflow: hidden; + line-height: inherit; + min-height: 2em; +} +.wiki-inline-select { + width: auto; + min-width: 90px; + padding: 2px 4px; +} +/* Title editing keeps its large type */ +.wiki-doc-title.wiki-inline-view, +.wiki-doc-title.wiki-inline-input { font-size: 30px; font-weight: 760; line-height: 1.18; } +.wiki-lead.wiki-inline-view, +.wiki-lead.wiki-inline-input { font-size: 16px; line-height: 1.6; } +.wiki-prose.wiki-inline-view, +.wiki-prose.wiki-inline-input { font-size: 14.5px; line-height: 1.65; } + +.wiki-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 8px; + border: 1px solid; + border-radius: 9px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + white-space: nowrap; +} +/* Gray out zoom +/- when autofit is active */ +.canvas-area.autofit-active .react-flow__controls-zoomin, +.canvas-area.autofit-active .react-flow__controls-zoomout { + opacity: 0.35; + pointer-events: none; +} + +[data-theme="light"] .react-flow__controls button { + background: var(--bg-panel); + border-color: var(--border-color); + color: var(--text-primary); + fill: var(--text-primary); +} +[data-theme="light"] .react-flow__controls button:hover { + background: var(--hover-bg); +} +[data-theme="light"] .react-flow__controls button svg { + fill: var(--text-primary); +} +[data-theme="light"] .react-flow__edge-path { + stroke: #666; +} +[data-theme="light"] .react-flow__edge-textbg { + fill: var(--bg-canvas); +} +[data-theme="light"] .react-flow__edge-text { + fill: var(--text-primary); +} + +/* ─── C4 Nodes ───────────────────────────────────────────────────────── */ +.c4-node { + position: relative; + border-radius: 6px; + overflow: visible; + font-family: inherit; +} + +.c4-node-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px; + height: 30px; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.06em; + color: rgba(255,255,255,0.92); + text-transform: uppercase; + flex-shrink: 0; +} + +.c4-node-label { + font-size: 22px; + font-weight: 800; + color: #fff; + line-height: 1.18; + padding: 6px 10px 3px; + overflow: hidden; + text-overflow: ellipsis; + /* Allow long names to wrap onto a second line so the empty space below + the title bar gets used instead of the name being truncated. */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + white-space: normal; + word-break: break-word; +} + +/* Expanded parents (System / Container) act as a section header — only the + title is shown (tech/desc are hidden in JSX). Use a single-line, smaller + label so the children below have a predictable, modest top offset. */ +.c4-node-expanded > .c4-node-label { + font-size: 16px; + font-weight: 700; + -webkit-line-clamp: 1; + white-space: nowrap; + padding: 4px 10px 2px; +} + +.c4-node-tech { + font-size: 14px; + color: rgba(255,255,255,0.88); + padding: 0 10px 3px; + font-style: italic; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.c4-node-desc { + font-size: 16px; + color: rgba(255,255,255,0.95); + padding: 4px 10px 8px; + line-height: 1.35; + /* Fill all the remaining vertical space inside the node body so there + is no dead empty area under short descriptions. The flex child grows + to fill its parent (.c4-node uses flex column), and the inner clamp + limits the rendered text to fit. */ + flex: 1 1 auto; + display: -webkit-box; + -webkit-line-clamp: 8; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + white-space: normal; + word-break: break-word; +} + +/* Cylinder shapes (Database, Queue) — content is centered between the + elliptical caps, so we must NOT let the description stretch with flex:1 + (it would push the label off-center / out of the cap area). The bumped + default fonts also overflow the small db/queue bboxes (often 130×80), + so size everything down inside cylinders. */ +.c4-database-node .c4-node-label, +.c4-queue-node .c4-node-label { + font-size: 15px; + font-weight: 700; + padding: 0 6px; + -webkit-line-clamp: 2; +} + +.c4-database-node .c4-node-tech, +.c4-queue-node .c4-node-tech { + font-size: 11px; + padding: 0 6px; +} + +.c4-database-node .c4-node-desc, +.c4-queue-node .c4-node-desc { + font-size: 11px; + padding: 2px 6px 0; + line-height: 1.25; + flex: 0 0 auto; /* keep group vertically centered */ + -webkit-line-clamp: 2; +} + +/* WebApp node — 210×140 with 18px title bar leaves only ~122px content area. + Scale down to match the available space (same pattern as database/queue). */ +.c4-webapp-node .c4-node-label { + font-size: 16px; + font-weight: 700; + padding: 0 6px 2px; + -webkit-line-clamp: 2; +} + +.c4-webapp-node .c4-node-tech { + font-size: 11px; + padding: 0 6px; +} + +.c4-webapp-node .c4-node-desc { + font-size: 11px; + padding: 2px 6px 0; + line-height: 1.25; + flex: 0 0 auto; + -webkit-line-clamp: 2; +} + +/* Person node — narrow box (~150px) sits below a 44px silhouette, leaving + ~120px for header + label + tech + desc. The default 22px label wraps + and crowds out tech/desc, so size everything down here too. */ +.c4-person-node .c4-node-label { + font-size: 14px; + font-weight: 700; + line-height: 1.18; + padding: 4px 6px 2px; + -webkit-line-clamp: 2; +} + +.c4-person-node .c4-node-tech { + font-size: 10px; + padding: 0 6px; +} + +.c4-person-node .c4-node-desc { + font-size: 11px; + line-height: 1.25; + padding: 2px 6px 4px; + flex: 0 1 auto; + -webkit-line-clamp: 3; +} + +.c4-node-collapse-btn { + width: 20px; height: 20px; + border: 1px solid rgba(255,255,255,0.35); + border-radius: 4px; + background: rgba(0,0,0,0.2); + color: rgba(255,255,255,0.8); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + flex-shrink: 0; + transition: background 0.1s; +} +.c4-node-collapse-btn:hover { background: rgba(0,0,0,0.4); } + +/* Person node: silhouette above the box, selection outline on body only */ +.c4-person-node { + background: transparent !important; + border: none !important; +} +.react-flow__node.selected .c4-person-node { outline: none; } +.react-flow__node.selected .c4-person-body { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Database cylinder: no background/border on wrapper */ +.c4-database-node { + background: transparent !important; + border: none !important; +} +.react-flow__node.selected .c4-database-node { outline: none; } +.react-flow__node.selected .c4-database-node svg { filter: drop-shadow(0 0 3px var(--accent)); } + +/* ─── React Flow overrides ───────────────────────────────────────────── */ +.react-flow__node { + border: none !important; + background: transparent !important; + padding: 0 !important; + border-radius: 6px; +} +.react-flow__node.selected .c4-node { outline: 2px solid var(--accent); outline-offset: 2px; } +.react-flow__node:focus { outline: none; } + +.react-flow__edge-path { stroke-width: 1.5; } +.react-flow__edge.selected .react-flow__edge-path { stroke: var(--accent); } + +.react-flow__handle { + opacity: 0 !important; + pointer-events: none !important; + width: 8px !important; + height: 8px !important; +} + +/* ── Modifier+click connection mode ── */ +.canvas-area.connect-ready .react-flow__node { cursor: crosshair; } +.canvas-area.connecting .react-flow__node { cursor: crosshair; } + +.react-flow__node.connecting-source .c4-node { + outline: 3px solid var(--accent) !important; + outline-offset: 3px; + box-shadow: 0 0 12px rgba(var(--accent-rgb, 99,102,241), 0.45); +} + +/* background */ +.react-flow__background { background: var(--bg-canvas); } + +/* Presentation picker */ +.pres-icon-btn.danger:hover { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.4); + color: #ef4444; +} +.pres-panel-btn.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.pres-panel-btn.primary:hover:not(:disabled) { opacity: 0.9; } + +/* Slide list */ +.pres-slide-card.active { + background: rgba(var(--accent-rgb, 99,102,241), 0.1); + border-color: var(--accent); +} +.pres-slide-card.active .pres-slide-num { + background: var(--accent); + color: #fff; +} +.pres-icon-btn.danger:hover { background: rgba(239,68,68,0.12); color: #ef4444; } +.pres-icon-btn.linked { color: var(--accent); } + +/* Snapshot picker */ +.pres-snap-item.active { color: var(--accent); font-weight: 600; } +.pres-snap-item.disabled { opacity: 0.5; pointer-events: none; } + +/* Presenter HUD (bottom bar during presentation) */ +.pres-hud-btn.exit { + border-color: rgba(239,68,68,0.4); + color: rgba(239,68,68,0.8); +} +.pres-hud-btn.exit:hover { background: rgba(239,68,68,0.15); color: #ef4444; } +.pres-hud-dot.active { + background: #fff; + transform: scale(1.3); +} +.pres-hud-dot:hover:not(.active) { background: rgba(255,255,255,0.5); } + +/* ── Milestone editing banner + modal ────────────────────────────── */ +.milestone-btn.primary { + background: var(--accent, #3b82f6); + border-color: var(--accent, #3b82f6); + color: #fff; +} +.milestone-btn.primary:hover { filter: brightness(1.1); } +.milestone-btn.ghost { + background: transparent; + border-color: transparent; + color: var(--text-secondary, var(--text-primary)); +} +.milestone-btn.ghost:hover { background: var(--bg-hover, rgba(255,255,255,0.06)); } + +.milestone-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.55); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} +.milestone-modal { + width: min(520px, 92vw); + background: var(--bg-panel, #1f2330); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 20px 22px; + box-shadow: 0 12px 40px rgba(0,0,0,0.5); +} +.milestone-modal-title { + margin: 0 0 6px; + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} +.milestone-modal-text { + margin: 0 0 14px; + font-size: 12.5px; + color: var(--text-secondary, var(--text-primary)); +} +.milestone-modal-options { + display: flex; + flex-direction: column; + gap: 8px; +} +.milestone-option { + text-align: left; + padding: 10px 12px; + background: var(--bg-toolbar); + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + color: var(--text-primary); +} +.milestone-option:hover { + border-color: var(--accent, #3b82f6); + background: var(--bg-hover, rgba(255,255,255,0.04)); +} +.milestone-option.ghost { background: transparent; } +.milestone-option-title { + font-size: 12.5px; + font-weight: 600; + margin-bottom: 4px; +} +.milestone-option-desc { + font-size: 11.5px; + color: var(--text-secondary, var(--text-primary)); + line-height: 1.45; +} + +/* "Current (live)" pseudo-milestone entry */ +.lp-snap-current { border-style: dashed; } +.lp-snap-badge-live { + background: #10b981; + color: #fff; + font-size: 14px; + line-height: 1; +} + +/* ─── Radical Forge — drops down from the top-center search bar, same + family as .quick-search-dropdown's own pop-open treatment. No backdrop: + canvas stays visible and interactive around it, closing is via ✕/Escape. */ +.forge-step:hover:not(:disabled):not(.active) { + color: var(--text-primary); + border-color: var(--text-muted); +} +.forge-step.skipped:not(.active) { text-decoration: line-through; border-style: dashed; } +.forge-step.done { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 45%, transparent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} +.forge-step.active { + color: #fff; + background: var(--accent); + border-color: var(--accent); + box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 45%, transparent); +} + +/* ─── Radical Forge — live progress feed ─────────────────────────────────── */ + +/* ─── Document manager modal ─────────────────────────────────────────────── */ +.docmgr-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} +.docmgr-tab.active .docmgr-tab-count { + background: var(--accent-soft); + color: var(--accent); +} +.docmgr-mm-option.active { + border-color: var(--accent); + background: var(--accent-soft); +} +.docmgr-btn.primary { + background: var(--accent); border-color: var(--accent); + color: #fff; +} +.docmgr-btn.primary:hover { filter: brightness(1.08); } +.docmgr-btn.small { padding: 3px 8px; font-size: 12px; } +.docmgr-btn.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); } +.docmgr-btn.danger:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); } +.docmgr-item.active { background: var(--accent-soft); } +.docmgr-badge.ls { + background: color-mix(in srgb, var(--text-muted) 22%, transparent); + color: var(--text-secondary); +} + +/* ─── Toolbar: active document chip ──────────────────────────────────────── */ +.toolbar-doc-badge.ls { background: rgba(120,120,120,0.25); color: #ccc; } +/* Reserve room in the right column for the absolute-positioned ✕ close + * button so the Issues count badge doesn't slide underneath it. */ +.mm-chevron.open { transform: rotate(90deg); } +.mm-icon-btn.danger:hover { + background: rgba(220, 38, 38, 0.12); + color: var(--danger); + border-color: rgba(220, 38, 38, 0.4); +} +.mm-btn.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; + display: inline-flex; + align-items: center; + gap: 6px; +} +.mm-btn.primary:hover { opacity: 0.9; } + +/* ─── Issues panel (metamodel side + LeftPanel accordion) ───────────── */ +.mm-issues-count.has { + background: var(--danger); + color: #fff; +} + +/* ─── Toast notifications (modern) ────────────────────────────────────── */ +.toast-host { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 10px; + width: min(420px, calc(100vw - 48px)); + pointer-events: none; +} +.toast { + --toast-accent: var(--accent); + --toast-accent-soft: rgba(99, 102, 241, 0.18); + position: relative; + pointer-events: auto; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: start; + gap: 12px; + padding: 14px 14px 16px 16px; + border-radius: 12px; + background: + linear-gradient(135deg, var(--toast-accent-soft), transparent 55%), + color-mix(in srgb, var(--bg-panel) 92%, transparent); + border: 1px solid color-mix(in srgb, var(--toast-accent) 35%, var(--border-color)); + backdrop-filter: blur(14px) saturate(140%); + -webkit-backdrop-filter: blur(14px) saturate(140%); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.04) inset, + 0 12px 32px rgba(0, 0, 0, 0.32), + 0 2px 6px rgba(0, 0, 0, 0.16); + color: var(--text-primary); + overflow: hidden; + transform-origin: bottom right; + animation: toast-in 220ms cubic-bezier(0.22, 1, 0.36, 1); + transition: transform 160ms ease, box-shadow 160ms ease; +} +.toast:hover { + transform: translateY(-1px); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.06) inset, + 0 16px 40px rgba(0, 0, 0, 0.38), + 0 4px 10px rgba(0, 0, 0, 0.22); +} +.toast.toast-leaving { + animation: toast-out 200ms cubic-bezier(0.4, 0, 1, 1) forwards; +} +.toast-error { --toast-accent: var(--danger); --toast-accent-soft: rgba(220, 38, 38, 0.20); } +.toast-warning { --toast-accent: #f59e0b; --toast-accent-soft: rgba(245, 158, 11, 0.20); } +.toast-info { --toast-accent: var(--accent); --toast-accent-soft: rgba(99, 102, 241, 0.18); } + +.toast-icon-wrap { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 10px; + background: color-mix(in srgb, var(--toast-accent) 22%, transparent); + color: var(--toast-accent); + flex-shrink: 0; +} +.toast-body { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + padding-top: 2px; +} +.toast-title { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--toast-accent); + text-transform: none; +} +.toast-message { + font-size: 13px; + line-height: 1.45; + color: var(--text-primary); + word-wrap: break-word; +} +.toast-close { + flex-shrink: 0; + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} +.toast-close:hover { + background: color-mix(in srgb, var(--text-primary) 10%, transparent); + color: var(--text-primary); +} +.toast-progress { + position: absolute; + left: 0; + bottom: 0; + height: 2px; + width: 100%; + background: linear-gradient(90deg, var(--toast-accent), color-mix(in srgb, var(--toast-accent) 50%, transparent)); + transform-origin: left center; + animation-name: toast-progress; + animation-timing-function: linear; + animation-fill-mode: forwards; +} + +@keyframes toast-in { + from { transform: translate(8px, 8px) scale(0.96); opacity: 0; } + to { transform: translate(0, 0) scale(1); opacity: 1; } +} +@keyframes toast-out { + from { transform: translate(0, 0) scale(1); opacity: 1; } + to { transform: translate(12px, 0) scale(0.98); opacity: 0; } +} +@keyframes toast-progress { + from { transform: scaleX(1); } + to { transform: scaleX(0); } +} +@media (prefers-reduced-motion: reduce) { + .toast, .toast.toast-leaving, .toast-progress { animation: none; } +} + +/* ─── Floating selection action bar ──────────────────────────────────── */ + +/* ─── Quick search palette (Cmd+P) ──────────────────────────────────── */ +.quick-search-item.active, +.quick-search-item:hover { + background: color-mix(in srgb, var(--accent) 18%, transparent); +} + +/* ─── Quick search · per-result view chips & view kind ────────────── */ +.quick-search-view-chip.active { + background: color-mix(in srgb, var(--accent) 22%, transparent); + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} +.quick-search-view-chip.more { + background: transparent; + border-style: dashed; +} + +.quick-search-view-chip.model-only { + background: transparent; + color: var(--text-muted); + border-style: dashed; + font-style: italic; + text-transform: lowercase; +} +.quick-search-view-chip.all { + background: transparent; + border-style: solid; + font-weight: 600; +} +.quick-search-view-chip.all.active { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +/* ─── Quick search · AI agent additions ───────────────────────────── */ +.qs-mode-toggle.active { + background: color-mix(in srgb, var(--accent) 22%, transparent); + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 50%, transparent); +} + +/* ─── AI Settings Modal ───────────────────────────────────────────── */ +.ai-settings-tab.editing { + color: var(--text-primary); + border-bottom-color: var(--accent); +} +.ai-settings-actions .ai-test-result.ok { background: color-mix(in srgb, #2ecc71 20%, transparent); color: #2ecc71; } +.ai-settings-actions .ai-test-result.err { background: color-mix(in srgb, #ff4444 20%, transparent); color: #ff7777; } +.ai-settings-actions .ai-test-result.busy { color: var(--text-muted); } + +/* ─── Diff overlay extras (removed ghosts + banner stats) ─────────── */ +.rf-node-ghost { + opacity: 0.45; + pointer-events: none; +} +.rf-node-ghost .react-flow__handle { display: none; } +.milestone-diff-stat.added { color: var(--success); } +.milestone-diff-stat.changed { color: var(--warning, #d97706); } +.milestone-diff-stat.removed { color: var(--danger); } + +/* ─── Welcome / Splash screen ─────────────────────────────────────────────── */ + +/* ── Left column ── */ +.welcome-mm-option.selected { + background: rgba(59, 111, 230, 0.07); + border-color: rgba(59, 111, 230, 0.35); +} + +/* ─── Treemap view ───────────────────────────────────────────────────── */ +.tm-bc-item.active { + color: var(--text-primary); + font-weight: 600; + cursor: default; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Table View (.tv-*) + ═══════════════════════════════════════════════════════════════════════════ */ + +.tv-wrap { + grid-area: canvas; + display: flex; + flex-direction: column; + min-height: 0; + background: var(--bg-canvas); + overflow: hidden; + position: relative; +} + +.tv-wrap.tv-drop-active { + outline: 2px solid var(--accent, #3b82f6); + outline-offset: -2px; +} + +.tv-drop-overlay { + position: absolute; + inset: 0; + background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); + display: flex; + align-items: center; + justify-content: center; + z-index: 20; + pointer-events: none; +} + +.tv-drop-overlay-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 20px 32px; + background: var(--bg-panel, #1e1e2e); + border: 2px dashed var(--accent, #3b82f6); + border-radius: 12px; + color: var(--accent, #3b82f6); + font-size: 14px; + font-weight: 600; + opacity: 0.95; +} + +.tv-drop-icon { + font-size: 32px; + line-height: 1; +} + +/* Compact pill shown when hovering a specific parent row */ +.tv-drop-pill { + position: absolute; + top: 48px; + left: 50%; + transform: translateX(-50%); + z-index: 21; + pointer-events: none; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--accent, #3b82f6); + border: 2px solid var(--accent, #3b82f6); + border-radius: 999px; + color: #fff; + font-size: 13px; + font-weight: 600; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); +} + +.tv-drop-pill .tv-drop-icon { + font-size: 18px; +} + +.tv-drop-pill strong { + color: #fff; + text-decoration: underline; + text-underline-offset: 2px; +} + +/* Highlighted row that will become the parent on drop */ +.tv-row.tv-row-drop-parent > .tv-td { + background: color-mix(in srgb, var(--accent, #3b82f6) 28%, transparent) !important; + box-shadow: inset 0 0 0 9999px color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); +} + +.tv-row.tv-row-drop-parent > .tv-td:first-child { + box-shadow: + inset 3px 0 0 0 var(--accent, #3b82f6), + inset 0 0 0 9999px color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); +} + +/* ── Tab bar ─────────────────────────────────────────────────────────────── */ + +.tv-tabs { + display: flex; + align-items: center; + gap: 2px; + padding: 6px 12px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.tv-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 14px; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.12s, color 0.12s; + white-space: nowrap; +} +.tv-tab:hover { + background: var(--hover-bg, rgba(255,255,255,0.06)); + color: var(--text); +} +.tv-tab.active { + background: var(--accent-soft, rgba(59,130,246,0.15)); + border-color: var(--accent, #3b82f6); + color: var(--text); + font-weight: 600; +} +.tv-tab-count { + display: inline-block; + min-width: 18px; + padding: 1px 5px; + border-radius: 10px; + background: var(--hover-bg, rgba(255,255,255,0.1)); + color: var(--text-muted); + font-size: 10px; + font-weight: 600; + text-align: center; +} +.tv-tab.active .tv-tab-count { + background: var(--accent, #3b82f6); + color: #fff; +} + +/* ── Table scroll wrapper ────────────────────────────────────────────────── */ + +.tv-table-wrap { + flex: 1 1 auto; + overflow: auto; + min-height: 0; +} + +/* ── Table ───────────────────────────────────────────────────────────────── */ + +.tv-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 12px; +} + +.tv-th { + position: sticky; + top: 0; + z-index: 2; + padding: 8px 10px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + background: var(--bg-panel); + border-bottom: 1px solid var(--border-color); + white-space: nowrap; + user-select: none; +} + +.tv-td { + padding: 0; + border-bottom: 1px solid var(--border-color); + vertical-align: middle; + max-width: 0; /* makes text-overflow work inside fixed cols */ +} + +.tv-row { + cursor: pointer; + transition: background 0.08s; +} +.tv-row:hover { background: var(--hover-bg-faint, rgba(255,255,255,0.03)); } +.tv-row:hover .tv-cell-placeholder { opacity: 0.5; } +.tv-row-selected { background: var(--accent-soft, rgba(59,130,246,0.1)) !important; } + +/* ── Cell content ────────────────────────────────────────────────────────── */ + +.tv-cell-readonly { + display: block; + padding: 8px 10px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tv-type-badge { + display: inline-block; + margin: 6px 10px; + padding: 2px 8px; + border-radius: 4px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.4px; + white-space: nowrap; + text-transform: uppercase; +} + +.tv-cell-value { + display: block; + padding: 8px 10px; + color: var(--text); + cursor: text; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-radius: 4px; + transition: background 0.1s; +} +.tv-cell-value:hover { + background: var(--hover-bg, rgba(255,255,255,0.06)); +} +.tv-cell-multiline { + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; + /* No explicit max-height here: it fights with -webkit-line-clamp's own + height calculation and can clip the second line mid-glyph instead of + showing the "…" the clamp would otherwise render — the clamp already + confines the box to 2 lines on its own. */ +} + +.tv-cell-placeholder { + color: var(--text-muted); + font-style: italic; + opacity: 0; + pointer-events: none; +} + +/* ── Inline inputs ───────────────────────────────────────────────────────── */ + +.tv-cell-input { + display: block; + width: 100%; + box-sizing: border-box; + padding: 7px 10px; + background: var(--bg-panel); + border: 1px solid var(--accent, #3b82f6); + border-radius: 4px; + color: var(--text); + font-size: 12px; + outline: none; + box-shadow: 0 0 0 2px rgba(59,130,246,0.25); +} +.tv-cell-textarea { + display: block; + width: 100%; + box-sizing: border-box; + padding: 7px 10px; + background: var(--bg-panel); + border: 1px solid var(--accent, #3b82f6); + border-radius: 4px; + color: var(--text); + font-size: 12px; + outline: none; + resize: vertical; + min-height: 64px; + box-shadow: 0 0 0 2px rgba(59,130,246,0.25); + line-height: 1.4; +} + +/* ── Boolean toggle ──────────────────────────────────────────────────────── */ + +.tv-bool { + display: inline-flex; + align-items: center; + justify-content: center; + margin: 6px 10px; + width: 28px; + height: 20px; + border-radius: 4px; + border: 1px solid var(--border-color); + background: var(--bg-panel); + color: var(--text-muted); + font-size: 12px; + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} +.tv-bool:hover { border-color: var(--accent, #3b82f6); } +.tv-bool-on { + background: var(--accent, #3b82f6); + border-color: var(--accent, #3b82f6); + color: #fff; + font-weight: 700; +} + +/* ── Empty state ─────────────────────────────────────────────────────────── */ + +.tv-empty { + padding: 48px 24px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + diff --git a/src/renderer/src/layout/liveColaLayout.ts b/packages/ui/src/layout/liveColaLayout.ts similarity index 99% rename from src/renderer/src/layout/liveColaLayout.ts rename to packages/ui/src/layout/liveColaLayout.ts index f6801209..c4fcd81c 100644 --- a/src/renderer/src/layout/liveColaLayout.ts +++ b/packages/ui/src/layout/liveColaLayout.ts @@ -12,8 +12,8 @@ import { d3adaptor, Layout, InputNode, Group, Link } from 'webcola' import { dispatch } from 'd3-dispatch' import { timer } from 'd3-timer' import { drag as d3drag } from 'd3-drag' -import { C4Node, C4Relation } from '../types/c4' -import { effectiveWidth, effectiveHeight, isVisible } from './geometry' +import { C4Node, C4Relation } from '@radical/common/c4' +import { effectiveWidth, effectiveHeight, isVisible } from '@radical/layout/geometry' // ─── Types ─────────────────────────────────────────────────────────────────── diff --git a/src/renderer/src/layout/smartLayout.worker.ts b/packages/ui/src/layout/smartLayout.worker.ts similarity index 90% rename from src/renderer/src/layout/smartLayout.worker.ts rename to packages/ui/src/layout/smartLayout.worker.ts index 00c4d454..25e66217 100644 --- a/src/renderer/src/layout/smartLayout.worker.ts +++ b/packages/ui/src/layout/smartLayout.worker.ts @@ -15,8 +15,8 @@ * | { type: 'progress', progress: SmartLayoutProgress } */ -import { runSmartLayoutWorkerPhase, type SmartLayoutResult, type RawCandidate, type SmartLayoutProgress } from './smartLayout' -import type { C4Node, C4Relation } from '../types/c4' +import { runSmartLayoutWorkerPhase, type SmartLayoutResult, type RawCandidate, type SmartLayoutProgress } from '@radical/layout/smartLayout' +import type { C4Node, C4Relation } from '@radical/common/c4' self.onmessage = async (e: MessageEvent) => { const { nodes, relations, raw } = e.data as { diff --git a/src/renderer/src/layout/smartLayoutRunner.ts b/packages/ui/src/layout/smartLayoutRunner.ts similarity index 95% rename from src/renderer/src/layout/smartLayoutRunner.ts rename to packages/ui/src/layout/smartLayoutRunner.ts index debbe1fb..2c754552 100644 --- a/src/renderer/src/layout/smartLayoutRunner.ts +++ b/packages/ui/src/layout/smartLayoutRunner.ts @@ -10,15 +10,15 @@ * smartLayout.ts (computation) and never references this file. */ -import type { C4Node, C4Relation } from '../types/c4' -import type { Metamodel } from '../types/metamodel' +import type { C4Node, C4Relation } from '@radical/common/c4' +import type { Metamodel } from '@radical/common/metamodel' import { runSmartLayoutCore, runSmartLayoutELKPhase, type SmartLayoutResult, type SmartLayoutProgress, type SmartLayoutOnProgress, -} from './smartLayout' +} from '@radical/layout/smartLayout' // Vite ?worker import — processed at build time into a separate worker chunk. // Static top-level import is required for Vite's worker plugin to detect it. import SmartLayoutWorkerClass from './smartLayout.worker?worker' diff --git a/src/renderer/src/runtime.ts b/packages/ui/src/runtime.ts similarity index 100% rename from src/renderer/src/runtime.ts rename to packages/ui/src/runtime.ts diff --git a/src/renderer/src/store/diagramStore.ts b/packages/ui/src/store/diagramStore.ts similarity index 90% rename from src/renderer/src/store/diagramStore.ts rename to packages/ui/src/store/diagramStore.ts index cd983c58..6feedc01 100644 --- a/src/renderer/src/store/diagramStore.ts +++ b/packages/ui/src/store/diagramStore.ts @@ -28,7 +28,7 @@ import { COLLAPSED_HEIGHT, COLLAPSED_WIDTH, isContainerType, -} from '../types/c4' +} from '@radical/common/c4' import { Metamodel, NodeTypeDef, @@ -40,15 +40,16 @@ import { isParentAllowed, canAddMoreOfType, inferRelationType, -} from '../types/metamodel' +} from '@radical/common/metamodel' +import * as model from '@radical/common/model' import type { HubImportRecord, HubConceptMeta } from './hubStore' -import { applyTreeLayout } from '../layout/elkLayout' -import { applyRadicalLayout } from '../layout/radicalLayout' +import { applyTreeLayout } from '@radical/layout/elkLayout' +import { applyRadicalLayout } from '@radical/layout/radicalLayout' import { runSmartLayout, type SmartLayoutProgress } from '../layout/smartLayoutRunner' -import { minimizeCrossings } from '../layout/crossingOpt' -import { compoundPadding } from '../layout/geometry' +import { minimizeCrossings } from '@radical/layout/crossingOpt' +import { compoundPadding } from '@radical/layout/geometry' import { LiveColaLayout } from '../layout/liveColaLayout' -import { documents, useDocumentsStore } from './documentStore' +import { documentBackend } from './documentBackend' import { isViewerProfile } from '../runtime' // ─── Smart Layout "why this layout" report ─────────────────────────────────── @@ -1298,7 +1299,6 @@ export const useDiagramStore = create()( } } const viewerProfile = isViewerProfile() - const activeMeta = viewerProfile ? null : documents.ensureActive(buildSampleData).meta let initNodes: Record let initRelations: Record @@ -1308,17 +1308,11 @@ export const useDiagramStore = create()( let initPres: ReturnType let initMetamodel: Metamodel - let persisted: DiagramData | null = null - if (activeMeta?.source === 'ls') { - // Synchronous LS read so the UI shows the right diagram on first paint. - try { - if (typeof localStorage !== 'undefined') { - const raw = localStorage.getItem('radical-doc:' + activeMeta.id) - if (raw) persisted = JSON.parse(raw) as DiagramData - } - } catch (e) { console.warn('[diagramStore] sync LS read failed:', e) } - } - // FS-backed docs: render sample first, then async hydrate (see below). + // A synchronously readable document (localStorage) shows on first paint. + // Anything else renders the sample first and the app loads it afterwards. + const persisted: DiagramData | null = viewerProfile + ? null + : documentBackend()?.openActive(buildSampleData) ?? null if (persisted && Array.isArray(persisted.nodes) && Array.isArray(persisted.relations)) { initNodes = {} @@ -1502,54 +1496,16 @@ export const useDiagramStore = create()( // ── nodes ──────────────────────────────────────────────────────────── addNode(node) { - const state0 = get() - const mm = state0.metamodel - const def = mm?.nodeTypes[node.type] - const typeLabel = def?.label ?? node.type - // Cardinality.max - const count = Object.values(state0.c4Nodes).filter(n => n.type === node.type).length - if (!canAddMoreOfType(mm, node.type, count)) { - get().pushNotification( - `Cannot add another ${typeLabel}: maximum (${def?.cardinality?.max}) reached in the metamodel.`, - 'error', - ) - return '' - } - // Parent containment - const parent = node.parentId ? state0.c4Nodes[node.parentId] : undefined - if (!isParentAllowed(mm, node.type, parent?.type)) { - const allowed = def?.allowedParents - const parentLabel = parent - ? (mm?.nodeTypes[parent.type]?.label ?? parent.type) - : 'the canvas root' - const allowedStr = allowed && allowed.length - ? allowed.map(t => mm?.nodeTypes[t]?.label ?? t).join(', ') - : 'the canvas root' - get().pushNotification( - `Cannot place ${typeLabel} inside ${parentLabel}. Allowed parents: ${allowedStr}.`, - 'error', - ) + // Cardinality and parent containment (metamodel rules) + const refused = model.checkAddNode(get(), node) + if (refused) { + get().pushNotification(refused, 'error') return '' } get()._pushUndo() get()._markMilestoneEdit() const id = uid() - set((state) => { - // Materialize metamodel property defaults (e.g. requirement - // ears_type='ubiquitous') so they exist on the node — and thus - // reach persistence — instead of living only in UI fallbacks. - const newNode = { id, ...node } as C4Node & Record - for (const p of def?.properties ?? []) { - if (p.default !== undefined && newNode[p.key] === undefined) { - newNode[p.key] = p.default - } - } - state.c4Nodes[id] = newNode - // Auto-add to active view - if (state.activeViewId && state.views[state.activeViewId]) { - state.views[state.activeViewId].nodeIds.push(id) - } - }) + set((state) => { model.insertNode(state, id, node) }) // Expand parent to fit the new child if (node.parentId) { get().fitParentToChildren(node.parentId) @@ -1560,51 +1516,16 @@ export const useDiagramStore = create()( }, updateNode(id, updates) { - const state0 = get() - const existing = state0.c4Nodes[id] - if (!existing) return - // If a reparent or retype is requested, validate against metamodel. - if ('parentId' in updates || 'type' in updates) { - const newType = (updates.type as string | undefined) ?? existing.type - const newParentId = ('parentId' in updates - ? (updates.parentId as string | null | undefined) - : existing.parentId) ?? undefined - const parent = newParentId ? state0.c4Nodes[newParentId] : undefined - if (!isParentAllowed(state0.metamodel, newType, parent?.type)) { - const def = state0.metamodel?.nodeTypes[newType] - const allowed = def?.allowedParents - const typeLabel = def?.label ?? newType - const parentLabel = parent - ? (state0.metamodel?.nodeTypes[parent.type]?.label ?? parent.type) - : 'the canvas root' - const allowedStr = allowed && allowed.length - ? allowed.map(t => state0.metamodel?.nodeTypes[t]?.label ?? t).join(', ') - : 'the canvas root' - get().pushNotification( - `Cannot move ${typeLabel} "${existing.label}" into ${parentLabel}. Allowed parents: ${allowedStr}.`, - 'error', - ) - return - } + if (!get().c4Nodes[id]) return + // A reparent or retype must respect the metamodel's containment rules. + const refused = model.checkNodeUpdate(get(), id, updates) + if (refused) { + get().pushNotification(refused, 'error') + return } get()._pushUndo() get()._markMilestoneEdit() - set((state) => { - const node = state.c4Nodes[id] as (C4Node & Record) | undefined - if (!node) return - Object.assign(node, updates) - // Retyping a node should materialize the new type's metamodel - // property defaults, same as addNode, so retyped and freshly - // created nodes of a type persist the same fields. - if ('type' in updates) { - const def = state.metamodel?.nodeTypes[node.type] - for (const p of def?.properties ?? []) { - if (p.default !== undefined && node[p.key] === undefined) { - node[p.key] = p.default - } - } - } - }) + set((state) => { model.patchNode(state, id, updates) }) get()._sync() // Only wake the live layout if the change actually affects geometry // or graph topology. Editing label / description / technology must @@ -1619,9 +1540,10 @@ export const useDiagramStore = create()( hydrateNode(id) { if (!get().pendingBodyNodeIds[id]) return - const activeId = documents.getActiveId() - if (!activeId) return - documents.hydrateNodeBody(activeId, id).then((body) => { + const docs = documentBackend() + const activeId = docs?.getActiveId() + if (!docs || !activeId) return + docs.hydrateNodeBody(activeId, id).then((body) => { // The node (or the whole doc) may have changed/closed while the // read was in flight — re-check before merging. if (!get().pendingBodyNodeIds[id]) return @@ -1637,12 +1559,13 @@ export const useDiagramStore = create()( const q = query.trim().toLowerCase() const matches = new Set() if (!q) return matches - const activeId = documents.getActiveId() + const docs = documentBackend() + const activeId = docs?.getActiveId() const pendingIds = Object.keys(get().pendingBodyNodeIds) - if (!activeId || pendingIds.length === 0) return matches + if (!docs || !activeId || pendingIds.length === 0) return matches const results = await Promise.all(pendingIds.map(async (id) => ({ id, - body: await documents.hydrateNodeBody(activeId, id), + body: await docs.hydrateNodeBody(activeId, id), }))) set((state) => { for (const { id, body } of results) { @@ -1662,25 +1585,8 @@ export const useDiagramStore = create()( removeNode(id) { get()._pushUndo() get()._markMilestoneEdit() - set((state) => { - // Remove node and all its descendants - const toRemove = new Set([id, ...getDescendants(id, state.c4Nodes)]) - for (const nid of toRemove) delete state.c4Nodes[nid] - // Remove relations touching removed nodes - for (const [rid, rel] of Object.entries(state.c4Relations)) { - if (toRemove.has(rel.sourceId) || toRemove.has(rel.targetId)) { - delete state.c4Relations[rid] - } - } - // Remove from all views (nodeIds and per-view collapse state) - for (const view of Object.values(state.views)) { - view.nodeIds = view.nodeIds.filter((nid) => !toRemove.has(nid)) - if (view.collapsedNodeIds?.length) - view.collapsedNodeIds = view.collapsedNodeIds.filter((nid) => !toRemove.has(nid)) - if (view.expandedNodeIds?.length) - view.expandedNodeIds = view.expandedNodeIds.filter((nid) => !toRemove.has(nid)) - } - }) + // The node, its descendants, their relations and every view reference + set((state) => { model.deleteNode(state, id) }) get()._sync() _liveLayout?.invalidate() }, @@ -1873,29 +1779,16 @@ export const useDiagramStore = create()( // whose (from, to) type pair is not permitted by the active // metamodel. Existing in-memory relations are left untouched and // surface as Issues instead. - const state0 = get() - const src = state0.c4Nodes[rel.sourceId] - const dst = state0.c4Nodes[rel.targetId] - if (src && dst && !isRelationAllowed(state0.metamodel, src.type, dst.type)) { - const srcLabel = state0.metamodel?.nodeTypes[src.type]?.label ?? src.type - const dstLabel = state0.metamodel?.nodeTypes[dst.type]?.label ?? dst.type - get().pushNotification( - `Relation not allowed: ${srcLabel} \u2192 ${dstLabel}. The metamodel does not permit this connection.`, - 'error', - ) + const refused = model.checkAddRelation(get(), rel) + if (refused) { + get().pushNotification(refused, 'error') return } - // Auto-infer relationType from metamodel when not explicitly provided. - const relationType = - rel.relationType ?? (src && dst - ? inferRelationType(state0.metamodel, src.type, dst.type) - : undefined) get()._pushUndo() get()._markMilestoneEdit() const id = uid() - set((state) => { - state.c4Relations[id] = { id, ...rel, ...(relationType ? { relationType } : {}) } - }) + // relationType is inferred from the metamodel when not given + set((state) => { model.insertRelation(state, id, rel) }) get()._sync() _liveLayout?.invalidate() }, @@ -1903,19 +1796,14 @@ export const useDiagramStore = create()( updateRelation(id, updates) { get()._pushUndo() get()._markMilestoneEdit() - set((state) => { - if (!state.c4Relations[id]) return - Object.assign(state.c4Relations[id], updates) - }) + set((state) => { model.patchRelation(state, id, updates) }) get()._sync() }, removeRelation(id) { get()._pushUndo() get()._markMilestoneEdit() - set((state) => { - delete state.c4Relations[id] - }) + set((state) => { model.deleteRelation(state, id) }) get()._sync() _liveLayout?.invalidate() }, @@ -2333,9 +2221,7 @@ export const useDiagramStore = create()( // ── views ──────────────────────────────────────────────────────────── addView(name) { const id = uid() - set((state) => { - state.views[id] = { id, name, nodeIds: [], positions: {} } - }) + set((state) => { model.insertView(state, id, name) }) return id }, removeView(id) { @@ -2470,21 +2356,10 @@ export const useDiagramStore = create()( get()._sync() }, setViewNodes(viewId, nodeIds) { - const state0 = get() - if (!state0.views[viewId]) return - // Filter out unknown ids defensively — the AI may emit a stale id. - const valid = nodeIds.filter((id) => id in state0.c4Nodes) - // Dedupe while preserving order. - const seen = new Set() - const ordered: string[] = [] - for (const id of valid) { - if (!seen.has(id)) { seen.add(id); ordered.push(id) } - } + if (!get().views[viewId]) return get()._pushUndo() - set((state) => { - const view = state.views[viewId] - if (view) view.nodeIds = ordered - }) + // Unknown ids are dropped (the AI may emit a stale one) and the rest deduplicated + set((state) => { model.setViewNodeIds(state, viewId, nodeIds) }) get()._sync() _liveLayout?.invalidate() }, @@ -2509,11 +2384,7 @@ export const useDiagramStore = create()( }, setViewKind(viewId, kind) { - set((state) => { - const view = state.views[viewId] - if (!view) return - view.kind = kind - }) + set((state) => { model.setViewKind(state, viewId, kind) }) get()._sync() }, @@ -4269,10 +4140,11 @@ export const useDiagramStore = create()( // Lazily-loaded md-folder docs report which node bodies weren't read // into `data` — track them so `hydrateNode` knows what to fetch. - const activeDocId = documents.getActiveId() + const docs = documentBackend() + const activeDocId = docs?.getActiveId() const pendingBody: Record = {} - if (activeDocId) { - for (const id of documents.getPendingBodyNodeIds(activeDocId)) pendingBody[id] = true + if (docs && activeDocId) { + for (const id of docs.getPendingBodyNodeIds(activeDocId)) pendingBody[id] = true } set((state) => { @@ -4296,14 +4168,7 @@ export const useDiagramStore = create()( state.presentationSlides = presInit.presentations[0].slides as any state.presentationActive = false state.presentationSlideIndex = 0 - state.metamodel = ((): any => { - const dm = data.metamodel - if (!dm) return builtInC4Metamodel() - if (dm.id === 'c4-builtin') return builtInC4Metamodel() - if (dm.id === 'c4-ddd-builtin') return builtInDddC4Metamodel() - if (dm.id === 'c4-ddd-governance-builtin') return builtInGovernanceMetamodel() - return dm - })() + state.metamodel = model.documentMetamodel(data.metamodel) as any state.hubTemplates = (data.hubTemplates ?? {}) as any state.hubMeta = data.hub ?? null }) @@ -4516,220 +4381,3 @@ if (typeof requestAnimationFrame !== 'undefined') { // overwrite the persisted positions. useDiagramStore.getState().startLiveLayout({ skipBulk: _initLoadedFromDisk }) } - -// ─── Auto-persist to the active document ──────────────────────────────────── -// Subscribe to model slices and debounce-save into whatever the current -// active document is (LS or FS, resolved fresh on every flush). -// The embedded viewer profile has no documents — skip the whole wiring so -// nothing it shows can ever leak into (or be read from) storage. -if (typeof window !== 'undefined' && !isViewerProfile()) { - let _persistTimer: ReturnType | null = null - let _suspended = false - // When a structural presentation edit (add/remove/rename/reorder/link a - // slide) happens in viewer/presenter mode, we still must persist it — but - // WITHOUT writing the ephemeral explored model layout to disk. This flag - // tells the next flush to rebuild the model slice from the pre-mode-layout - // snapshot (captured by setAppMode) so only the presentation change lands. - let _layoutSafePending = false - const buildPersistData = (): DiagramData => { - const data = useDiagramStore.getState().saveDiagram() - // In explore mode, override the model slice with the layout snapshot taken - // when we left designer, so ephemeral drags/collapses don't reach disk. - const pre = (window as any).__preModeLayout as - | { c4Nodes: Record; c4Relations: Record; views: Record; defaultPositions: Record } - | undefined - if (_layoutSafePending && pre) { - data.nodes = Object.values(pre.c4Nodes) - data.relations = Object.values(pre.c4Relations) - data.views = Object.values(pre.views) - data.defaultPositions = pre.defaultPositions - } - return data - } - const flushPersist = async (): Promise => { - if (_suspended) return - const activeId = documents.getActiveId() - if (!activeId) return - try { - const data = buildPersistData() - _layoutSafePending = false - await documents.saveDocument(activeId, data) - } catch (e) { - console.warn('[diagramStore] persist failed:', e) - } - } - const schedulePersist = (): void => { - if (_persistTimer !== null) clearTimeout(_persistTimer) - _persistTimer = setTimeout(() => { - _persistTimer = null - void flushPersist() - }, 400) - } - - let prev = useDiagramStore.getState() - useDiagramStore.subscribe((s) => { - // In viewer/presenter ("explore" mode), drags / collapses mutate the - // model temporarily but must NEVER reach disk. The setAppMode snapshot - // restores everything on the way back to designer, so by simply not - // scheduling a persist while we're outside designer, the explore - // session stays fully ephemeral. - const isExploreMode = s.appMode !== 'designer' && s.appMode !== 'metamodel' - const modelChanged = - s.c4Nodes !== prev.c4Nodes || - s.c4Relations !== prev.c4Relations || - s.sequences !== prev.sequences || - s.views !== prev.views || - s.defaultPositions !== prev.defaultPositions || - s.defaultViewport !== prev.defaultViewport || - s.snapshots !== prev.snapshots || - s.presentations !== prev.presentations || - s.metamodel !== prev.metamodel - // Presentation slide management (add/remove/rename/reorder/link) is a - // genuine, durable edit even though the slides dock lives in presenter - // mode. Persist those changes too — but layout-safe, so the ephemeral - // explored model layout is not written alongside them. - const presChangedInExplore = - isExploreMode && s.presentations !== prev.presentations - if (!isExploreMode && modelChanged) { - prev = s - schedulePersist() - } else if (presChangedInExplore) { - prev = s - _layoutSafePending = true - schedulePersist() - } else { - prev = s - } - }) - - // ── Hydrate FS-backed active doc on boot ───────────────────────────────── - // We rendered the sample synchronously above; if the active doc is FS, - // load the file now and replace the in-memory model. We suspend the - // auto-persist while doing this so the "sample → loaded" replacement - // doesn't overwrite the file with stale data. - const activeId = documents.getActiveId() - if (activeId) { - const meta = documents.listDocuments().find(d => d.id === activeId) - if (meta?.source === 'fs' || meta?.source === 'md') { - _suspended = true - documents.loadDocument(activeId).then((data) => { - if (data) { - useDiagramStore.getState().loadDiagram(data) - } else { - // New/empty file: initialize with the maximum built-in metamodel - useDiagramStore.getState().loadDiagram({ - nodes: [], - relations: [], - metamodel: builtInGovernanceMetamodel(), - }) - } - }).catch((e) => console.warn('[diagramStore] FS hydrate failed:', e)) - .finally(() => { _suspended = false }) - } - } - - // ── React to active-document switches ──────────────────────────────────── - // When the user picks a different document in the manager modal, load it. - let prevActive = documents.getActiveId() - useDocumentsStore.subscribe((s) => { - if (s.activeId === prevActive) return - prevActive = s.activeId - if (!s.activeId) return - _suspended = true - const switchMeta = documents.listDocuments().find(d => d.id === s.activeId) - documents.loadDocument(s.activeId).then((data) => { - if (data) { - useDiagramStore.getState().loadDiagram(data) - } else if (switchMeta?.source === 'fs' || switchMeta?.source === 'md') { - // New/empty file: initialize with the maximum built-in metamodel - useDiagramStore.getState().loadDiagram({ - nodes: [], - relations: [], - metamodel: builtInGovernanceMetamodel(), - }) - } - }).catch((e) => console.warn('[diagramStore] switch-doc load failed:', e)) - .finally(() => { _suspended = false }) - }) - - // Expose hooks for other modules / future use. - ;(window as any).__radicalFlushPersist = flushPersist - - // ── Synchronous flush on tab close / reload / hide ─────────────────────── - // LocalStorage writes are synchronous, so we can safely persist pending - // changes during pagehide / visibilitychange. This prevents a quick - // reload (within the 400 ms debounce) from losing the most recent edit - // (e.g. a freshly-applied smart layout). - const flushSync = (): void => { - if (_suspended) return - if (_persistTimer === null) return // nothing pending - clearTimeout(_persistTimer) - _persistTimer = null - const activeId = documents.getActiveId() - if (!activeId) return - const meta = documents.listDocuments().find(d => d.id === activeId) - // FS writes go through async electronAPI and won't reliably complete - // on pagehide; LS writes are synchronous and always make it to disk. - if (meta?.source !== 'ls') { - // Best effort — fire-and-forget. Pending FS save may still complete - // if the unload races slowly enough; if not, the user will see the - // pre-edit state next time. - void flushPersist() - return - } - try { - const data = buildPersistData() - _layoutSafePending = false - void documents.saveDocument(activeId, data) - } catch (e) { - console.warn('[diagramStore] sync flush failed:', e) - } - } - if (typeof window.addEventListener === 'function') { - window.addEventListener('pagehide', flushSync) - window.addEventListener('beforeunload', flushSync) - } - if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') flushSync() - }) - } - - // ── CLI-specified watched file ──────────────────────────────────────────── - // When Radical.Tools is launched with --file /path/to/model.c4.json (or the - // RADICAL_FILE env var), Electron's main process watches the file and pushes - // a 'file:external-change' IPC message whenever it changes on disk. - // - // 1. On boot: ensure the file is registered as an FS-backed doc and - // activate it. The existing switch-doc subscriber (above) will load it. - // 2. On external change: suspend auto-persist, reload the model from the - // new content, then re-enable persist. This lets the user edit the file - // in any external editor and see changes reflected in real-time. - // - // Writing back from the app is handled by the existing auto-persist path - // (saveDocument → file:write IPC), which already targets FS-backed docs. - if (window.electronAPI?.getWatchedPath) { - void window.electronAPI.getWatchedPath().then((watchedPath) => { - if (!watchedPath) return - const meta = documents.createFSDocument(watchedPath) - // Activate (triggers the switch-doc subscriber above → loads the file). - documents.setActiveId(meta.id) - }) - } - - if (window.electronAPI?.onFileChanged) { - window.electronAPI.onFileChanged(({ content }) => { - _suspended = true - try { - const data = JSON.parse(content) as DiagramData - useDiagramStore.getState().loadDiagram(data) - } catch (e) { - console.warn('[diagramStore] external file change — invalid JSON, ignored:', e) - } finally { - // Brief delay so the store's _sync() and React render cycle can - // complete before auto-persist is re-enabled. - setTimeout(() => { _suspended = false }, 300) - } - }) - } -} diff --git a/packages/ui/src/store/documentBackend.ts b/packages/ui/src/store/documentBackend.ts new file mode 100644 index 00000000..ad328f10 --- /dev/null +++ b/packages/ui/src/store/documentBackend.ts @@ -0,0 +1,33 @@ +// ─── Document backend ──────────────────────────────────────────────────────── +// +// How the diagram store reaches the document it is editing. The store itself +// knows nothing about documents or storage: an app that has documents (Studio) +// configures a backend *before the store module is first imported*, because +// the store reads the active document to build its initial state. An app +// without documents (the read-only Hub viewer) configures nothing. +// +// Saving is not part of this interface: the app subscribes to the store and +// persists changes itself (see apps/studio persistence/). + +import type { DiagramData } from '@radical/common/c4' + +export interface DocumentBackend { + /** Makes sure there is an active document, creating one from `seed` when + * there is none, and returns its content if it can be read synchronously + * (for the first paint). Null when it has to be loaded asynchronously. */ + openActive(seed: () => DiagramData): DiagramData | null + getActiveId(): string | null + /** Node descriptions of markdown-folder documents load on demand. */ + hydrateNodeBody(docId: string, nodeId: string): Promise + getPendingBodyNodeIds(docId: string): string[] +} + +let backend: DocumentBackend | null = null + +export function configureDocumentBackend(next: DocumentBackend | null): void { + backend = next +} + +export function documentBackend(): DocumentBackend | null { + return backend +} diff --git a/src/renderer/src/store/hubStore.ts b/packages/ui/src/store/hubStore.ts similarity index 89% rename from src/renderer/src/store/hubStore.ts rename to packages/ui/src/store/hubStore.ts index a2719648..51ae5ca6 100644 --- a/src/renderer/src/store/hubStore.ts +++ b/packages/ui/src/store/hubStore.ts @@ -5,42 +5,15 @@ import { type HubConcept, type HubConceptSummary, type HubRadicalDoc, - type TemplateParam, -} from '../hub/hubFormat' +} from '@radical/common/hubFormat' -export type { HubConcept, HubConceptMeta, HubConceptSummary, HubCategory, TemplateParam } from '../hub/hubFormat' +export type { HubConcept, HubConceptMeta, HubConceptSummary, HubCategory, HubImportRecord, TemplateParam } from '@radical/common/hubFormat' // ─── Hub catalogue ─────────────────────────────────────────────────────────── // // `hub/index.json` lists concept summaries; each concept is a Radical Studio // document at `hub//.radical` fetched on demand (see hubFormat.ts). -/** - * Persisted record of a hub concept import that used template parameters. - * Stored in the diagram so the user can reconfigure values later. - */ -export interface HubImportRecord { - conceptId: string - conceptName: string - templateParams: TemplateParam[] - /** Current substitution values (may change on reconfigure). */ - paramValues: Record - /** New node IDs (UUIDs) that were created by this import. */ - nodeIds: string[] - /** - * Original template nodes keyed by new node UUID. - * Contains the {{TOKEN}} placeholders before substitution. - * Positions (x/y/width/height) are excluded — they are user-managed. - */ - originalNodes: Record> - /** - * Per-node template params keyed by new node UUID. - * Derived from node-level templateParams at import time. - * Used by RightPanel to show only the params relevant to a given node. - */ - nodeParams?: Record -} - // ─── Store types ──────────────────────────────────────────────────────────── interface HubState { diff --git a/src/renderer/src/studioSettings.ts b/packages/ui/src/studioSettings.ts similarity index 100% rename from src/renderer/src/studioSettings.ts rename to packages/ui/src/studioSettings.ts diff --git a/src/renderer/src/types/hubTheme.ts b/packages/ui/src/types/hubTheme.ts similarity index 76% rename from src/renderer/src/types/hubTheme.ts rename to packages/ui/src/types/hubTheme.ts index 1d996992..21f080e2 100644 --- a/src/renderer/src/types/hubTheme.ts +++ b/packages/ui/src/types/hubTheme.ts @@ -4,13 +4,14 @@ // site, the in-app import modal and (via the node-type tokens) the studio // canvas. A hub *category* is mapped onto the C4 node type it produces, so a // Requirement card carries the same colour / icon / label as a Requirement -// node on the canvas. `hub/theme.js` is generated from this file -// (`npm run hub:theme`); never edit it by hand. +// node on the canvas. -import type { C4ElementType } from './c4' -import { NODE_COLORS, NODE_FG, TYPE_LABELS, TYPE_ICON_PATHS } from './c4' +import type { C4ElementType } from '@radical/common/c4' +import { NODE_COLORS, NODE_FG, TYPE_LABELS, TYPE_ICON_PATHS } from '@radical/common/c4' +import type { HubCategory } from '@radical/common/hubFormat' + +export type { HubCategory } -export type HubCategory = 'pattern' | 'fitness-function' | 'requirement' | 'adr' | 'blueprint' export const HUB_CATEGORIES: readonly HubCategory[] = [ 'pattern', @@ -85,21 +86,3 @@ export function categoryTheme(category: string): VisualToken { export function nodeTypeTheme(type: string): VisualToken { return NODE_TYPE_THEME[type as C4ElementType] ?? { ...FALLBACK_TOKEN, label: type, plural: type } } - -/** Serializable payload consumed by the vanilla-JS hub site. */ -export function buildHubTheme(): { - categories: Record - nodeTypes: Record - fallback: VisualToken -} { - return { categories: CATEGORY_THEME, nodeTypes: NODE_TYPE_THEME, fallback: FALLBACK_TOKEN } -} - -export function renderHubThemeScript(): string { - return ( - '/* GENERATED by tools/generate-hub-theme.ts from src/renderer/src/types/hubTheme.ts — do not edit. */\n' + - 'window.RADICAL_HUB_THEME = ' + - JSON.stringify(buildHubTheme(), null, 2) + - ';\n' - ) -} diff --git a/packages/ui/src/viewer.ts b/packages/ui/src/viewer.ts new file mode 100644 index 00000000..286fc8cc --- /dev/null +++ b/packages/ui/src/viewer.ts @@ -0,0 +1,20 @@ +// ─── Viewer kit ────────────────────────────────────────────────────────────── +// +// Everything the read-only Hub viewer (apps/hub) needs, as one entry point: +// `@radical/ui/viewer`. +// +// Set `window.__RADICAL_PROFILE = 'viewer'` before importing this module; the +// diagram store reads the profile at import time. + +export { Canvas } from './components/Canvas' +export { WikiView } from './components/WikiView' +export { TableView } from './components/TableView' +export { RightPanel } from './components/RightPanel' +export { NotificationHost } from './components/NotificationHost' +export { SmartLayoutButton } from './components/SmartLayoutButton' +export { ErrorBoundary } from './components/ErrorBoundary' +export { useDiagramStore } from './store/diagramStore' +export { useHubStore, buildConnectionCounts, toggleInSet, type HubSortKey } from './store/hubStore' +export { HUB_CATEGORIES, categoryTheme } from './types/hubTheme' +export { conceptViewsToDiagram } from './hub/conceptViews' +export { sanitizeWireframeSvg } from '@radical/common/wireframe' diff --git a/tests/setup.ts b/packages/ui/testing/setup.ts similarity index 100% rename from tests/setup.ts rename to packages/ui/testing/setup.ts diff --git a/tests/autoFit.test.ts b/packages/ui/tests/autoFit.test.ts similarity index 98% rename from tests/autoFit.test.ts rename to packages/ui/tests/autoFit.test.ts index ea6fbc3f..de507e5b 100644 --- a/tests/autoFit.test.ts +++ b/packages/ui/tests/autoFit.test.ts @@ -10,7 +10,7 @@ * - re-init via setFitViewFn doesn't break an active loop */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { useDiagramStore } from '../src/renderer/src/store/diagramStore' +import { useDiagramStore } from '../src/store/diagramStore' // jsdom-like minimal window stub for the timer registry the store uses. beforeEach(() => { diff --git a/tests/collapsePhysics.test.ts b/packages/ui/tests/collapsePhysics.test.ts similarity index 95% rename from tests/collapsePhysics.test.ts rename to packages/ui/tests/collapsePhysics.test.ts index 13292608..1440fc51 100644 --- a/tests/collapsePhysics.test.ts +++ b/packages/ui/tests/collapsePhysics.test.ts @@ -11,9 +11,9 @@ * If physics dies on collapse, step 4 never produces fresh emissions. */ import { describe, it, expect } from 'vitest' -import { LiveColaLayout } from '../src/renderer/src/layout/liveColaLayout' -import type { C4Node, C4Relation } from '../src/renderer/src/types/c4' -import { NODE_SIZES } from '../src/renderer/src/types/c4' +import { LiveColaLayout } from '../src/layout/liveColaLayout' +import type { C4Node, C4Relation } from '@radical/common/c4' +import { NODE_SIZES } from '@radical/common/c4' function wait(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)) diff --git a/tests/connection.test.ts b/packages/ui/tests/connection.test.ts similarity index 96% rename from tests/connection.test.ts rename to packages/ui/tests/connection.test.ts index ad43f5bf..8e7bc626 100644 --- a/tests/connection.test.ts +++ b/packages/ui/tests/connection.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { useDiagramStore } from '../src/renderer/src/store/diagramStore' +import { useDiagramStore } from '../src/store/diagramStore' describe('Connection via store actions', () => { it('startConnection sets connectSource', () => { diff --git a/tests/_group_collapse_debug.test.ts b/packages/ui/tests/groupCollapse.test.ts similarity index 96% rename from tests/_group_collapse_debug.test.ts rename to packages/ui/tests/groupCollapse.test.ts index b2d52434..08858ed0 100644 --- a/tests/_group_collapse_debug.test.ts +++ b/packages/ui/tests/groupCollapse.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { useDiagramStore } from '../src/renderer/src/store/diagramStore' -import { COLLAPSED_HEIGHT, COLLAPSED_WIDTH, NODE_SIZES, type C4Node, type DiagramView } from '../src/renderer/src/types/c4' +import { useDiagramStore } from '../src/store/diagramStore' +import { COLLAPSED_HEIGHT, COLLAPSED_WIDTH, NODE_SIZES, type C4Node, type DiagramView } from '@radical/common/c4' const initial = (() => { const s = useDiagramStore.getState() diff --git a/packages/ui/tests/hubRoundTrip.test.ts b/packages/ui/tests/hubRoundTrip.test.ts new file mode 100644 index 00000000..8b7b8770 --- /dev/null +++ b/packages/ui/tests/hubRoundTrip.test.ts @@ -0,0 +1,29 @@ +/** + * Opening a Hub concept in Studio must keep its `hub` metadata block, so the + * document can be saved back into the catalogue unchanged. + */ +import { describe, it, expect } from 'vitest' +import type { HubRadicalDoc } from '@radical/common/hubFormat' +import { useDiagramStore } from '../src/store/diagramStore' + +const doc: HubRadicalDoc = { + hub: { + id: 'req-x', category: 'requirement', name: 'X', description: 'd', tags: ['a'], + templateParams: [{ key: 'K', label: 'k' }], + }, + nodes: [ + { id: 'n1', type: 'requirement', label: 'L', ears_type: 'ubiquitous', priority: 'must', status: '' }, + { id: 'n2', type: 'container', label: 'C', parentId: 'n1' }, + ], + relations: [{ id: 'r1', sourceId: 'n2', targetId: 'n1' }], +} + +describe('studio round-trip', () => { + it('loadDiagram → saveDiagram keeps the hub block (opening a concept in Studio must not strip it)', () => { + const store = useDiagramStore.getState() + store.loadDiagram({ nodes: [], relations: [], hub: doc.hub }) + expect(useDiagramStore.getState().saveDiagram().hub).toEqual(doc.hub) + store.newDiagram() + expect(useDiagramStore.getState().saveDiagram().hub).toBeUndefined() + }) +}) diff --git a/tests/milestones.test.ts b/packages/ui/tests/milestones.test.ts similarity index 99% rename from tests/milestones.test.ts rename to packages/ui/tests/milestones.test.ts index daa8f0d8..78c20b3c 100644 --- a/tests/milestones.test.ts +++ b/packages/ui/tests/milestones.test.ts @@ -19,8 +19,8 @@ * - removeSnapshot deletes the milestone; if active, restores live HEAD */ import { describe, it, expect, beforeEach } from 'vitest' -import { useDiagramStore } from '../src/renderer/src/store/diagramStore' -import type { C4Node, C4Relation, DiagramSequence } from '../src/renderer/src/types/c4' +import { useDiagramStore } from '../src/store/diagramStore' +import type { C4Node, C4Relation, DiagramSequence } from '@radical/common/c4' const initial = (() => { const s = useDiagramStore.getState() diff --git a/tests/model.test.ts b/packages/ui/tests/model.test.ts similarity index 97% rename from tests/model.test.ts rename to packages/ui/tests/model.test.ts index f3c3dcde..4066c023 100644 --- a/tests/model.test.ts +++ b/packages/ui/tests/model.test.ts @@ -9,9 +9,9 @@ * - metamodel cardinality / parent containment is enforced */ import { describe, it, expect, beforeEach } from 'vitest' -import { useDiagramStore } from '../src/renderer/src/store/diagramStore' -import { builtInGovernanceMetamodel } from '../src/renderer/src/types/metamodel' -import type { C4Node, C4Relation, DiagramView } from '../src/renderer/src/types/c4' +import { useDiagramStore } from '../src/store/diagramStore' +import { builtInGovernanceMetamodel } from '@radical/common/metamodel' +import type { C4Node, C4Relation, DiagramView } from '@radical/common/c4' // Snapshot the pristine sample on import so each test starts from the same // state. We only need the bits the model actions touch. diff --git a/tests/presentation.test.ts b/packages/ui/tests/presentation.test.ts similarity index 98% rename from tests/presentation.test.ts rename to packages/ui/tests/presentation.test.ts index 5290f8e5..605fe2d4 100644 --- a/tests/presentation.test.ts +++ b/packages/ui/tests/presentation.test.ts @@ -18,8 +18,8 @@ * presentation mode; sets presentationSlideIndex so Present starts there */ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { useDiagramStore } from '../src/renderer/src/store/diagramStore' -import type { C4Node, C4Relation } from '../src/renderer/src/types/c4' +import { useDiagramStore } from '../src/store/diagramStore' +import type { C4Node, C4Relation } from '@radical/common/c4' const initial = (() => { const s = useDiagramStore.getState() @@ -146,7 +146,7 @@ describe('goToSlide', () => { return new Promise((resolve) => { setTimeout(() => { expect(setVP).toHaveBeenCalled() - const [vpArg] = setVP.mock.calls.at(-1)! + const [vpArg] = setVP.mock.calls[setVP.mock.calls.length - 1] expect(vpArg).toEqual({ x: 100, y: 200, zoom: 1.5 }) resolve() }, 100) diff --git a/tests/presenterMode.test.ts b/packages/ui/tests/presenterMode.test.ts similarity index 97% rename from tests/presenterMode.test.ts rename to packages/ui/tests/presenterMode.test.ts index 3600602c..1ac94eab 100644 --- a/tests/presenterMode.test.ts +++ b/packages/ui/tests/presenterMode.test.ts @@ -18,8 +18,8 @@ * - presenter → viewer transition keeps snapshot intact */ import { describe, it, expect, beforeEach } from 'vitest' -import { useDiagramStore } from '../src/renderer/src/store/diagramStore' -import type { C4Node, C4Relation } from '../src/renderer/src/types/c4' +import { useDiagramStore } from '../src/store/diagramStore' +import type { C4Node, C4Relation } from '@radical/common/c4' const initial = (() => { const s = useDiagramStore.getState() @@ -122,7 +122,7 @@ describe('mutation sandboxing in presenter', () => { it('a node added in presenter is reverted when returning to designer', () => { useDiagramStore.getState().setAppMode('presenter') const countBefore = Object.keys(initial.c4Nodes).length - useDiagramStore.getState().addNode({ type: 'system', label: 'SandboxedNode', x: 0, y: 0 }) + useDiagramStore.getState().addNode({ type: 'system', label: 'SandboxedNode', x: 0, y: 0 } as Omit) expect(Object.keys(useDiagramStore.getState().c4Nodes).length).toBe(countBefore + 1) useDiagramStore.getState().setAppMode('designer') // snapshot restores original — the added node is gone diff --git a/tests/sequences.test.ts b/packages/ui/tests/sequences.test.ts similarity index 98% rename from tests/sequences.test.ts rename to packages/ui/tests/sequences.test.ts index 49c0909d..9cbac536 100644 --- a/tests/sequences.test.ts +++ b/packages/ui/tests/sequences.test.ts @@ -16,8 +16,8 @@ * sequence is subsequently deleted */ import { describe, it, expect, beforeEach } from 'vitest' -import { useDiagramStore } from '../src/renderer/src/store/diagramStore' -import type { C4Node, C4Relation } from '../src/renderer/src/types/c4' +import { useDiagramStore } from '../src/store/diagramStore' +import type { C4Node, C4Relation } from '@radical/common/c4' const initial = (() => { const s = useDiagramStore.getState() diff --git a/tests/snapshotMemorySharing.test.ts b/packages/ui/tests/snapshotMemorySharing.test.ts similarity index 98% rename from tests/snapshotMemorySharing.test.ts rename to packages/ui/tests/snapshotMemorySharing.test.ts index 69ecdaad..ba5eee97 100644 --- a/tests/snapshotMemorySharing.test.ts +++ b/packages/ui/tests/snapshotMemorySharing.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { useDiagramStore } from '../src/renderer/src/store/diagramStore' +import { useDiagramStore } from '../src/store/diagramStore' // These tests guard the memory-dedup refactor in diagramStore.ts: snapshots, // undo history, and presentation slides now reference the live model instead diff --git a/tests/undo.test.ts b/packages/ui/tests/undo.test.ts similarity index 96% rename from tests/undo.test.ts rename to packages/ui/tests/undo.test.ts index 6995d47f..20beeba7 100644 --- a/tests/undo.test.ts +++ b/packages/ui/tests/undo.test.ts @@ -9,8 +9,8 @@ * - undo on an empty stack is a no-op */ import { describe, it, expect, beforeEach } from 'vitest' -import { useDiagramStore } from '../src/renderer/src/store/diagramStore' -import type { C4Node, C4Relation } from '../src/renderer/src/types/c4' +import { useDiagramStore } from '../src/store/diagramStore' +import type { C4Node, C4Relation } from '@radical/common/c4' const initial = (() => { const s = useDiagramStore.getState() diff --git a/tests/useOutsideClick.test.ts b/packages/ui/tests/useOutsideClick.test.ts similarity index 98% rename from tests/useOutsideClick.test.ts rename to packages/ui/tests/useOutsideClick.test.ts index ce0e7bc2..e500254b 100644 --- a/tests/useOutsideClick.test.ts +++ b/packages/ui/tests/useOutsideClick.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { useOutsideClick } from '../src/renderer/src/hooks/useOutsideClick' +import { useOutsideClick } from '../src/hooks/useOutsideClick' // Minimal React renderer shim for the hook. We don't need a full DOM — // useOutsideClick only relies on: diff --git a/tests/viewCollapse.test.ts b/packages/ui/tests/viewCollapse.test.ts similarity index 99% rename from tests/viewCollapse.test.ts rename to packages/ui/tests/viewCollapse.test.ts index 5eb6abac..9205a225 100644 --- a/tests/viewCollapse.test.ts +++ b/packages/ui/tests/viewCollapse.test.ts @@ -14,8 +14,8 @@ * expanded in a named view without affecting other views. */ import { describe, it, expect, beforeEach } from 'vitest' -import { useDiagramStore } from '../src/renderer/src/store/diagramStore' -import { COLLAPSED_HEIGHT, COLLAPSED_WIDTH, type C4Node, type DiagramView } from '../src/renderer/src/types/c4' +import { useDiagramStore } from '../src/store/diagramStore' +import { COLLAPSED_HEIGHT, COLLAPSED_WIDTH, type C4Node, type DiagramView } from '@radical/common/c4' const initial = (() => { const s = useDiagramStore.getState() diff --git a/tests/views.test.ts b/packages/ui/tests/views.test.ts similarity index 98% rename from tests/views.test.ts rename to packages/ui/tests/views.test.ts index a15003a3..20c30f5b 100644 --- a/tests/views.test.ts +++ b/packages/ui/tests/views.test.ts @@ -16,8 +16,8 @@ * - addViewFromSequence creates a dynamic view with nodes from the sequence */ import { describe, it, expect, beforeEach } from 'vitest' -import { useDiagramStore } from '../src/renderer/src/store/diagramStore' -import type { C4Node, C4Relation, DiagramView } from '../src/renderer/src/types/c4' +import { useDiagramStore } from '../src/store/diagramStore' +import type { C4Node, C4Relation, DiagramView } from '@radical/common/c4' const initial = (() => { const s = useDiagramStore.getState() diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 00000000..6333a737 --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src/**/*", "testing/**/*", "tests/**/*"] +} diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts new file mode 100644 index 00000000..a69cf937 --- /dev/null +++ b/packages/ui/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + setupFiles: ['./testing/setup.ts'], + include: ['tests/**/*.test.ts'], + }, +}) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts deleted file mode 100644 index 4c7b9b70..00000000 --- a/src/preload/index.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -export {} - -declare global { - interface Window { - electronAPI: { - platform: string - send: (channel: string, ...args: unknown[]) => void - on: (channel: string, listener: (...args: unknown[]) => void) => void - invoke: (channel: string, ...args: unknown[]) => Promise - saveDiagram: (json: string) => Promise<{ success: boolean; filePath?: string }> - openDiagram: () => Promise<{ success: boolean; filePath?: string; content?: string }> - readFile: (filePath: string) => Promise<{ success: boolean; content?: string; error?: string }> - writeFile: (filePath: string, json: string) => Promise<{ success: boolean; error?: string }> - openFolder: () => Promise<{ success: boolean; folderPath?: string; files?: Record; error?: string }> - pickFolder: () => Promise<{ success: boolean; folderPath?: string }> - readFolder: (folderPath: string) => Promise<{ success: boolean; files?: Record; error?: string }> - writeFolder: (folderPath: string, files: Record) => Promise<{ success: boolean; error?: string }> - devSaveSample: (json: string) => Promise<{ success: boolean; error?: string }> - devLoadSample: () => Promise<{ success: boolean; content?: string; error?: string }> - getWatchedPath: () => Promise - onFileChanged: (listener: (data: { filePath: string; content: string }) => void) => void - } - } -} diff --git a/src/preload/index.ts b/src/preload/index.ts deleted file mode 100644 index 10bf3ce8..00000000 --- a/src/preload/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { contextBridge, ipcRenderer } from 'electron' - -// Expose a minimal safe API to renderer -const api = { - platform: process.platform, - send: (channel: string, ...args: unknown[]) => ipcRenderer.send(channel, ...args), - on: (channel: string, listener: (...args: unknown[]) => void) => { - ipcRenderer.on(channel, (_event, ...args) => listener(...args)) - }, - invoke: (channel: string, ...args: unknown[]) => ipcRenderer.invoke(channel, ...args), - saveDiagram: (json: string): Promise<{ success: boolean; filePath?: string }> => - ipcRenderer.invoke('dialog:save', json), - openDiagram: (): Promise<{ success: boolean; filePath?: string; content?: string }> => - ipcRenderer.invoke('dialog:open'), - readFile: (filePath: string): Promise<{ success: boolean; content?: string; error?: string }> => - ipcRenderer.invoke('file:read', filePath), - writeFile: (filePath: string, json: string): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('file:write', filePath, json), - openFolder: (): Promise<{ success: boolean; folderPath?: string; files?: Record; error?: string }> => - ipcRenderer.invoke('folder:open'), - pickFolder: (): Promise<{ success: boolean; folderPath?: string }> => - ipcRenderer.invoke('folder:pick'), - readFolder: (folderPath: string): Promise<{ success: boolean; files?: Record; error?: string }> => - ipcRenderer.invoke('folder:read', folderPath), - writeFolder: (folderPath: string, files: Record): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('folder:write', folderPath, files), - devSaveSample: (json: string): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('dev:saveSample', json), - devLoadSample: (): Promise<{ success: boolean; content?: string; error?: string }> => - ipcRenderer.invoke('dev:loadSample'), - getWatchedPath: (): Promise => - ipcRenderer.invoke('file:getWatchedPath'), - onFileChanged: (listener: (data: { filePath: string; content: string }) => void): void => { - ipcRenderer.on('file:external-change', (_event, data) => listener(data as { filePath: string; content: string })) - }, -} - -if (process.contextIsolated) { - try { - contextBridge.exposeInMainWorld('electronAPI', api) - } catch (error) { - console.error(error) - } -} else { - // @ts-ignore - window.electronAPI = api -} diff --git a/src/renderer/src/components/DevSampleToolbar.tsx b/src/renderer/src/components/DevSampleToolbar.tsx deleted file mode 100644 index e9a20120..00000000 --- a/src/renderer/src/components/DevSampleToolbar.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/** - * DEV-ONLY panel for editing the built-in sample model. - * - * Workflow: - * 1. Click "Load sample (raw)" — opens the sample instantly (no layout wait) - * as a new editable LS document. - * 2. Make changes in the diagram (move nodes, add labels, tweak structure …). - * 3. Click "Save → sample source" — serialises the current diagram state and - * writes it to `src/renderer/src/store/fintechSampleData.json` inside the - * project. Vite HMR picks up the change immediately. - * Next time someone clicks "Open sample model" on the WelcomeScreen the - * saved data is used directly (no layout computation needed). - * - * The toolbar is mounted only when `import.meta.env.DEV === true`. - */ - -import React, { useState } from 'react' -import { documents } from '../store/documentStore' -import { buildFintechSampleRaw, loadFintechSample } from '../store/fintechSample' -import { useDiagramStore } from '../store/diagramStore' - -interface Props { - onSampleLoaded?: () => void -} - -export function DevSampleToolbar({ onSampleLoaded }: Props): React.ReactElement { - const saveDiagram = useDiagramStore((s) => s.saveDiagram) - const [saving, setSaving] = useState(false) - const [lastSaved, setLastSaved] = useState(null) - - function handleLoad(): void { - loadFintechSample().then((raw) => { - const meta = documents.createLSDocument('[DEV] Fintech Sample', raw) - documents.setActiveId(meta.id) - onSampleLoaded?.() - }) - } - - async function handleSave(): Promise { - if (!window.electronAPI?.devSaveSample) { - alert('devSaveSample IPC not available — are you running in Electron dev mode?') - return - } - setSaving(true) - try { - const data = saveDiagram() - const json = JSON.stringify(data, null, 2) - const res = await window.electronAPI.devSaveSample(json) - if (res.success) { - setLastSaved(new Date().toLocaleTimeString()) - } else { - alert(`Save failed: ${res.error ?? 'unknown error'}`) - } - } finally { - setSaving(false) - } - } - - async function handleReset(): Promise { - if (!window.electronAPI?.devSaveSample) return - if (!confirm('Reset fintechSampleData.json to empty ({})? Next "Open sample model" will use hardcoded positions.')) return - await window.electronAPI.devSaveSample('{}') - setLastSaved(null) - } - - return ( -
- DEV · sample - - - - {lastSaved && saved {lastSaved}} -
- ) -} diff --git a/src/renderer/src/components/ExportMenu.tsx b/src/renderer/src/components/ExportMenu.tsx deleted file mode 100644 index ca5a4bab..00000000 --- a/src/renderer/src/components/ExportMenu.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import React, { useState, useRef, useCallback, useEffect } from 'react' -import { toPng, toSvg } from 'html-to-image' -import { useDiagramStore } from '../store/diagramStore' -import { useDocumentsStore } from '../store/documentStore' -import { useOutsideClick } from '../hooks/useOutsideClick' - -// ── Icons ───────────────────────────────────────────────────────────────────── - -const IconExport = () => ( - - - - -) - -const IconSVG = () => ( - - - - - - -) - -const IconPNG = () => ( - - - - - - - -) - -const IconChevron = () => ( - - - -) - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function sanitizeFilename(name: string): string { - return name.replace(/[^a-z0-9_\-. ]/gi, '_').trim() || 'diagram' -} - -function downloadBlob(blob: Blob, filename: string): void { - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - a.click() - URL.revokeObjectURL(url) -} - -// ── Component ───────────────────────────────────────────────────────────────── - -export function ExportMenu(): React.ReactElement | null { - const [open, setOpen] = useState(false) - const [busy, setBusy] = useState(false) - const wrapRef = useRef(null) - - const presentationActive = useDiagramStore((s) => s.presentationActive) - const activeViewId = useDiagramStore((s) => s.activeViewId) - const views = useDiagramStore((s) => s.views) - const snapshots = useDiagramStore((s) => s.snapshots) - const activeSnapshotId = useDiagramStore((s) => s.activeSnapshotId) - - const docs = useDocumentsStore((s) => s.docs) - const activeDocId = useDocumentsStore((s) => s.activeId) - const activeDoc = activeDocId ? docs.find((d) => d.id === activeDocId) ?? null : null - - const activeViewName = activeViewId ? (views[activeViewId]?.name ?? 'view') : 'view' - const activeMilestoneName = activeSnapshotId - ? (snapshots.find((s) => s.id === activeSnapshotId)?.name ?? null) - : null - const modelName = activeDoc?.name ?? 'model' - - useOutsideClick([wrapRef], open, useCallback(() => setOpen(false), [])) - - useEffect(() => { - if (!open) return - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }, [open]) - - if (presentationActive) return null - - const getTarget = (): HTMLElement | null => - document.querySelector('.canvas-area') as HTMLElement | null - - const buildFilename = (): string => { - const parts = [modelName, activeViewName] - if (activeMilestoneName) parts.push(activeMilestoneName) - return sanitizeFilename(parts.join('-')) - } - - const doExport = async (format: 'png' | 'svg') => { - const el = getTarget() - if (!el) return - setBusy(true) - setOpen(false) - const filename = buildFilename() - try { - const options = { - cacheBust: true, - backgroundColor: getComputedStyle(el).backgroundColor || '#1e1e2e', - pixelRatio: window.devicePixelRatio || 2, - // Exclude UI overlays from the export - filter: (node: HTMLElement | SVGElement) => { - const cls = (node as HTMLElement).className ?? '' - if (typeof cls !== 'string') return true - // Skip toolbar, panels, action bars, badges - if (cls.includes('toolbar') || cls.includes('left-panel') || cls.includes('right-panel')) return false - if (cls.includes('sel-bar') || cls.includes('edge-action') || cls.includes('quick-search')) return false - return true - }, - } - if (format === 'png') { - const dataUrl = await toPng(el, options) - const res = await fetch(dataUrl) - const blob = await res.blob() - downloadBlob(blob, `${filename}.png`) - } else { - const svgDataUrl = await toSvg(el, options) - const res = await fetch(svgDataUrl) - const blob = await res.blob() - downloadBlob(blob, `${filename}.svg`) - } - } catch (err) { - console.error('[Export]', err) - } finally { - setBusy(false) - } - } - - return ( -
- - - {open && ( -
-
Export view
- - -
- )} -
- ) -} diff --git a/src/renderer/src/components/PropertiesPanel.tsx b/src/renderer/src/components/PropertiesPanel.tsx deleted file mode 100644 index 47df0280..00000000 --- a/src/renderer/src/components/PropertiesPanel.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import React, { useCallback, ChangeEvent } from 'react' -import { useDiagramStore } from '../store/diagramStore' -import { C4ElementType, NODE_COLORS, TYPE_LABELS, TYPE_ICON_PATHS, NODE_FG } from '../types/c4' - -const TYPE_BG: Record = NODE_COLORS - -export function PropertiesPanel({ open, onToggle }: { open: boolean; onToggle: () => void }): React.ReactElement { - const selectedNodeId = useDiagramStore((s) => s.selectedNodeId) - const selectedEdgeId = useDiagramStore((s) => s.selectedEdgeId) - const c4Nodes = useDiagramStore((s) => s.c4Nodes) - const c4Relations = useDiagramStore((s) => s.c4Relations) - const updateNode = useDiagramStore((s) => s.updateNode) - const removeNode = useDiagramStore((s) => s.removeNode) - const updateRelation = useDiagramStore((s) => s.updateRelation) - const removeRelation = useDiagramStore((s) => s.removeRelation) - const selectNode = useDiagramStore((s) => s.selectNode) - const selectEdge = useDiagramStore((s) => s.selectEdge) - - // ── Node properties ────────────────────────────────────────────────────── - if (selectedNodeId && c4Nodes[selectedNodeId]) { - const node = c4Nodes[selectedNodeId] - - const field = ( - label: string, - key: keyof typeof node, - type: 'text' | 'textarea' | 'checkbox' = 'text' - ) => { - const value = node[key] - const onChange = (e: ChangeEvent) => { - updateNode(node.id, { - [key]: type === 'checkbox' - ? (e.target as HTMLInputElement).checked - : e.target.value, - }) - } - - if (type === 'checkbox') { - return ( -
- -
- ) - } - if (type === 'textarea') { - return ( -
- -