Skip to content

Turn the repo into an npm workspaces monorepo - #106

Merged
tomasz-zajac-oss merged 20 commits into
mainfrom
refactor/monorepo-workspaces
Sep 29, 2026
Merged

tomasz-zajac-oss merged 20 commits into
mainfrom
refactor/monorepo-workspaces

Conversation

@tomasz-zajac-oss

Copy link
Copy Markdown
Collaborator

Summary

Turns the repository into an npm workspaces monorepo with clear boundaries between the apps and the code they share. This replaces the multi-repo plan in #98: the Hub viewer reuses Studio's canvas and store, and the VS Code extension ships Studio's web build, so separate repos would turn every change to the shared format into a publish plus version bumps in several places.

Layout

Workspace Package What it is
apps/studio radical-model The editor: Electron app and studio.radical.tools. The package name stays, because Electron's user data folder depends on it.
apps/hub @radical/hub hub.radical.tools, the read-only concept viewer. It no longer depends on Studio.
apps/vscode vscode-radical The VS Code extension. It ships Studio's web build as its webview.
apps/web @radical/web The marketing site and manual, deployed as static files.
apps/mcp @radical/mcp An empty placeholder for a future MCP server.
packages/common @radical/common The model: C4 types, metamodels, file formats, query language, AI tool definitions and a headless model facade. No UI or browser code.
packages/layout @radical/layout Smart Layout and the other headless layout engines.
packages/ui @radical/ui The canvas, panels, views and diagram store shared by Studio and the Hub. Apps plug in documents and AI.
packages/host-bridge @radical/host-bridge The typed contract between Studio and its host: Electron, VS Code or a browser.
packages/hub-catalogue @radical/hub-catalogue The concept catalogue, its validation and the Vite plugin that bundles it.

Boundaries

npm run check:workspaces runs in CI and fails when:

  • a package depends on an app, or an app depends on another app. The one allowed exception is the VS Code extension using Studio's web build.
  • a relative import leaves its workspace.
  • a workspace imports a package it does not declare.
  • a tsconfig, script or Vite config reaches into another workspace.

@radical/common and @radical/layout compile without DOM types, so browser code there fails the typecheck.

Also in this branch

  • Studio reaches its host only through host() from @radical/host-bridge.
  • Model edits are shared between the store and a headless createModelFacade. A parity test keeps them in step.
  • Documents and AI are out of the Hub's import graph. They plug into the store through documentBackend and wireframeGeneration.
  • Dead components, dead CSS and the dev-sample chain are removed. Studio-only CSS moved to apps/studio.
  • CI and all workflows point at the new paths.
  • docs/IMPROVEMENTS.md lists what is done and what is still open.

Related issue

Supersedes #98.

Type of change

  • Refactor / code improvement
  • Tests
  • Documentation

Checklist

  • npm test passes
  • npm run typecheck passes with no errors
  • New or changed behaviour is covered by tests
  • Documentation updated if needed (README, CHANGELOG)

How this was checked

Every workflow was replayed on a clean clone with npm ci and Node 24:

  • CI: the boundary check, typecheck and all tests pass.
  • Deploy: the Studio web and Hub builds succeed, and every path uploaded to S3 exists.
  • VS Code publish: the webview copy, compile and vsce package succeed.
  • Desktop release: npm run dist builds both macOS DMGs. The packaged app contains the main process, preload, renderer and its runtime dependency.

Not checked: the Windows and Linux installers, and the uploads to S3, CloudFront and the Marketplace.

Before merging

Merging deploys. A push to main runs the deploy workflow, which rebuilds and publishes studio.radical.tools, hub.radical.tools and radical.tools together.

Known issues, not fixed here

These came up in a review of the new structure. None are regressions from this branch.

  • The release workflow's v* tag filter also matches vscode-v*. The first extension release will start a desktop release that fails on the version check. v[0-9]* fixes it.
  • Hub import probably fails in the desktop app and VS Code. Hub's CORS policy does not allow the null or vscode-webview:// origins, and the local /hub/ fallback is an absolute path that no production build can reach.
  • Electron's file IPC accepts any path from the renderer. The sandbox is off, and setWindowOpenHandler passes any URL to shell.openExternal.
  • Studio and @radical/ui still talk through window globals such as __radicalFlushPersist, which bypasses documentBackend.
  • The store is configured by import order. Studio must import autosave before the store, and the Hub sets its profile with an inline script.
  • CI on pull requests does not build. A broken Vite build or extension compile first shows up in the deploy on main.

🤖 Generated with Claude Code

Tomasz Zajac and others added 20 commits September 29, 2026 09:46
Move each product into its own workspace so shared code can later be
extracted into packages/ without splitting repositories:

- apps/studio   Studio (Electron + web) and the Hub viewer, with its tests,
                build configs and tools
- apps/vscode   VS Code extension (was tools/vscode-radical)
- apps/web      marketing site (was website/)
- content/hub   Hub concept catalogue (was hub/)

Root scripts (dev, build, build:web, dist, test, typecheck) forward to the
workspaces, so CI commands stay the same; workflows point at the new
output paths.

The Studio package keeps the name "radical-model": Electron derives the
userData directory from it, and renaming it would hide users' saved
documents. Electron is pinned to the locked 29.4.6 because
electron-builder cannot resolve a version range from hoisted node_modules.

The extension no longer commits its webview build or .vsix files; CI
copies the Studio web build in at package time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move the UI-free parts of Studio into workspace packages so the MCP server
and other tools can reuse them:

- @radical/common: C4 and metamodel types, hub document format, Structurizr
  import, markdown-folder persistence, Gherkin export, the query language,
  DiagramFacade and the AI tool catalogue. No dependencies; type-checks
  without the DOM lib.
- @radical/layout: Smart Layout and the headless engines (ELK ensemble,
  Radical layout, crossing minimisation, annealing, port allocation, edge
  routing). Depends on @radical/common and elkjs only.

To make the boundary hold:
- HubImportRecord moves from hubStore into hubFormat, so c4.ts no longer
  imports the store.
- The layout's node sides use their own Position type with reactflow's
  string values instead of importing reactflow; RelationEdge casts at the
  reactflow boundary.
- The browser download for Gherkin files stays in Studio.
- Tests that only exercise package code move with it; two latent type
  errors in them are fixed now that packages type-check their tests.

Packages ship .ts source (no build step) and Studio lists them as
devDependencies, so they are bundled into the renderer and stay out of
app.asar. hubCatalogue.ts imports hubFormat by relative path because the
Vite config loader cannot run a bare-imported .ts package.

Smart Layout output is byte-identical before and after the move on 25
fixtures (fintech sample, all hub blueprints and patterns).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
runSmartLayout moved into the Web Worker runner in #105, so the harness
crashed on import. runSmartLayoutCore takes the same arguments and returns
the same result shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Placeholder for the MCP server, wired to @radical/common and
@radical/layout. The README records the plan: serve the shared AI tool
catalogue over a headless DiagramFacade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Hub was a second HTML entry in Studio's bundle. It is now a workspace
of its own with its own Vite build and deploy output:

- apps/hub/catalogue  the concept catalogue (was content/hub)
- apps/hub/src        the read-only viewer (HubApp, landing, nav, routing,
                      concept-to-diagram mapping)
- apps/hub/tools      the catalogue plugin that validates it and emits
                      hub/index.json and hub-data.json
- apps/hub/tests      viewer and catalogue tests

The viewer still runs on Studio's canvas, panels and stores. Studio exposes
them through one narrow entry point, `radical-model/viewer` (plus
`radical-model/styles.css`), so the coupling is explicit and marks what a
later packages/ui extraction has to carry.

Studio no longer builds hub.html. It still bundles the catalogue under hub/
so Hub import works offline and in development. deploy.yml now builds the
Hub with `npm run build:hub` and uploads apps/hub/out.

Hub tests are not type-checked, matching Studio; hubCatalogue.test.ts has
latent type errors that predate the move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ireframes

The canvas, Wiki and properties panel only need to sanitise and display a
mockup's stored SVG, but they imported it from ai/mockupWireframe, which
pulls in every AI provider adapter. That put the Anthropic, OpenAI and
Gemini clients into the read-only Hub bundle.

- sanitizeWireframeSvg, wireframeDataUri and the size constants move to
  @radical/common/wireframe: they operate on model content and have no
  dependencies.
- MockupWireframe loads the generator with a dynamic import when the user
  clicks Generate, so Vite emits it as a separate chunk.

The Hub's main bundle no longer contains any provider endpoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@radical/common held the whole ai/types module: provider ids, AISettings,
provider adapters and the chat wire format. Those describe Studio's LLM
client. The shared tool catalogue only needs ToolDef, and an MCP server is
called by a model rather than calling one.

ToolDef moves next to the tool handlers in @radical/common/ai/tools; the
rest of ai/types moves back to apps/studio and re-exports ToolDef.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… public APIs

Both packages exported "./*", so every module was public, including
Smart Layout internals such as the annealing stage and score weights.
exports now lists exactly the entry points apps use. Package tests import
internals by relative path.

Smart Layout output is unchanged on the 25 fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
liveColaLayout imports d3-dispatch, d3-drag and d3-timer, which only
resolved because reactflow's copies were hoisted. They are now declared
at the versions already in the lockfile.

hubTheme defined its own copy of HubCategory; it now re-exports the one
from @radical/common/hubFormat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Studio and the Hub depended on each other: the Hub imported Studio's viewer
kit, and Studio's Vite configs and tests reached into apps/hub for the
catalogue and its plugin. The catalogue is now a package both apps use:

- packages/hub-catalogue/catalogue  the .radical concepts
- @radical/hub-catalogue            readCatalogue / validateCatalogue /
                                    buildIndex / CATALOGUE_DIR
- @radical/hub-catalogue/vite       hubCataloguePlugin(), defaulting to the
                                    package's own catalogue

The `./vite` entry is a small CommonJS file that loads the TypeScript
plugin through tsx. The Vite and electron-vite config loaders hand bare
imports to Node, which cannot run .ts; this replaces the relative import
into packages/common/src.

The Hub's remaining links to Studio now go through the package too:
viewer.ts references the preload globals it depends on, and the test
setup is exported as `radical-model/testing/setup`.

The "studio round-trip" test checks Studio's store, so it moves to
apps/studio. The catalogue tests are type-checked now; their loosely typed
relation fields get explicit String() conversions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tools/check-workspaces.mjs (npm run check:workspaces, run in CI and before
deploys) checks that:

- packages depend only on packages, and apps only on packages, except the
  app-to-app links listed with a reason (Hub -> Studio viewer kit, VS Code
  extension -> Studio web build);
- relative imports stay inside their workspace;
- every imported package is declared;
- tsconfig includes and scripts stay inside their workspace;
- every workspace with source or tests has typecheck / test scripts, since
  the root runs them with --if-present.

Imports are read with TypeScript's pre-processor rather than regexes.

It found two issues, fixed here:
- apps/studio/tools/generate-sample.js imports elkjs, which Studio stopped
  declaring when layout moved out; elkjs is back as a dev dependency.
- The VS Code extension copied the Studio build through ../studio. It now
  declares radical-model and resolves the build through it. The extension
  also gets a typecheck script, so CI type-checks it for the first time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Studio's renderer called window.electronAPI directly in 31 places. The
contract was typed only in Studio's preload .d.ts, and the VS Code
extension re-implemented part of it as an untyped script pasted into the
webview HTML.

@radical/host-bridge now defines the contract:
- HostBridge / HostCapabilities, with every capability optional and
  failures resolved as success: false;
- ./vscode: the webview <-> extension message protocol, the webview-side
  host, and vscodeBootScript.

- Studio reaches its host only through host() in platform/host.ts. It is
  resolved on each call, because tests install a fake Electron API after
  import. The VS Code host is cached because acquireVsCodeApi() can only be
  called once.
- The Electron preload is typed with `satisfies Required<HostCapabilities>`.
  It drops the generic send / on / invoke / platform members, which the
  renderer never used.
- The extension injects only boot data (the edited file) and types its
  message handlers with the shared protocol.

Two bugs fixed along the way:
- In VS Code, a failed readFile rejected the promise, where Electron
  returns success: false. loadDocument threw instead of returning null.
- Toolbar treated any host with electronAPI as VS Code. In the desktop app
  the theme always started dark and the user's choice was never saved.

Verified the VS Code path in headless Chrome, with a fake acquireVsCodeApi
serving a model: Studio reads the boot file, renders it and writes it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rules and edits behind every model change lived inside Studio's zustand
store, so the AI tool catalogue could only run against the live UI. They now
live in @radical/common/model:

- check* functions return the refusal Studio shows (metamodel cardinality,
  parent containment, allowed relation pairs), with the same wording;
- insert / patch / delete functions for nodes, relations and views work on a
  plain object or an immer draft (defaults materialised, relation types
  inferred, deletes cascade to descendants, relations and view references);
- documentMetamodel() resolves a document's stored metamodel.

Studio's store calls them and keeps its own effects: undo, milestones,
notifications, canvas sync, live layout, fitting parents.

createModelFacade (@radical/common/ai/modelFacade) implements DiagramFacade
over a plain DiagramData, so the MCP server can run the same tools. It
records why a change was refused in lastError, and carries over the
document fields it does not edit.

apps/studio's modelFacadeParity test starts both facades from the same
Hub blueprint and runs 16 tool calls, including three the metamodel
refuses. Tool results and the resulting model must match, ignoring ids
and geometry. It fails when the headless facade skips validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The components the Hub viewer shares with Studio pulled in Studio's
documents, host bridge and AI providers through the diagram store and
MockupWireframe. Two seams make them optional:

- store/documentBackend.ts: the store reads the document it starts from, and
  lazily loaded node bodies, through a backend the app configures before
  the store module is evaluated. Studio configures one on top of
  documentStore (persistence/configureDocuments.ts); the Hub configures
  none.
- The autosave block (save on change, load on boot and on document switch,
  flush on page hide, follow the host's watched file) moves unchanged from
  the store into Studio's persistence/autosave.ts. main.tsx imports it
  first, and the tests load it through a second setup file, because
  imports run before setup.ts stubs window.
- components/wireframeGeneration.ts: MockupWireframe offers generation only
  when the app registers a generator. Studio registers its AI-backed one
  (ai/wireframeGenerator.ts).

The viewer entry's import closure goes from 34 files to 25, with no AI,
document or host code left in it. Without the persistence setup, 4 tests
fail (lazy node bodies, presenter-mode autosave), so the tests exercise the
moved code. The Studio web build still seeds and renders its document in
headless Chrome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With documents and AI injected, the viewer kit's import closure no longer
depends on Studio. It moves to packages/ui: the Canvas, properties panel,
Wiki and Table views, node and edge renderers, the diagram and hub stores,
the Smart Layout worker runner, live cola layout and the global stylesheet.

- The Hub imports @radical/ui/viewer and @radical/ui/styles.css, so it no
  longer depends on Studio. Its app-to-app exception is removed from the
  workspace boundary check. The only one left is the VS Code extension,
  which ships Studio's web build.
- Studio imports the shared parts from @radical/ui and keeps its own:
  documents, autosave, AI, the toolbar, extra views and modals.
- The test stubs move to @radical/ui/testing/setup and both apps use them.
- Studio drops immer and @radical/layout, which it no longer imports.

@radical/ui exports explicit entry points and type-checks on its own,
without Studio's preload globals.

The Hub's main bundle contains no Electron bridge, File System Access,
VS Code bridge, document index or AI provider code, and shrinks from 2.15
to 2.12 MB (most of what remains is ELK and reactflow). The Hub, the Studio
web build and the simulated VS Code webview all render in headless Chrome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing imports any of this:

- PropertiesPanel, Sidebar and ViewBar, orphaned since 17f9ec1;
  TimeTravelBar and ExportMenu; DevSampleToolbar, whose removal fe346d1
  announced but did not carry out.
- The developer sample-override chain that only DevSampleToolbar used:
  loadFintechSample, the devSaveSample / devLoadSample host capabilities,
  their preload entries, the dev:saveSample / dev:loadSample IPC handlers,
  and the __DEV_SAMPLE_DATA_PATH__ define, which nothing read.
- PresentationBar's SlidesColumn (PresenterDock still uses its helpers),
  layoutFinalize's fitParentsToChildren wrapper, and ai/types'
  contentToText.
- hubTheme's buildHubTheme / renderHubThemeScript and the comment about a
  generator (tools/generate-hub-theme.ts, npm run hub:theme) that is not
  in the repository's history.

Smart Layout output is unchanged on the 25 fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
182 rules in the shared stylesheet styled class names that appear nowhere
in the code of Studio, the Hub or @radical/ui. Among them are the dynamic
view shell (its components went in d7575e6), the old sidebar, view bar,
slide strip, toolbar dropdowns and snapshot list. Also removed are 3
@Keyframes nothing refers to (welcome-spin, tm-zoom-in, tm-zoom-out) and 7
section comments left with no rules. index.css shrinks from 184 to 156 KB.

A class counts as used when its name appears anywhere in the sources,
including dynamic prefixes (`name-${x}`, 'name-' + x). The remaining
stylesheet is an ordered subset of the original: no rule is changed or
reordered.

Computed styles of about 30,000 elements across 12 Studio and Hub screens
(canvas, wiki, table, matrix, treemap, sequence, viewer, welcome; hub
landing, browse, concept, blueprint) match the previous build. The only
differences are sub-pixel column widths of animating panels, which also
differ between two runs of the same build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
467 rules in @radical/ui's stylesheet styled only Studio's own UI:
the app layout, toolbar, document manager, Forge, the Matrix, Treemap and
Sequence views, the welcome screen and modals. They move, with their section
headings and 8 @Keyframes, to apps/studio/src/renderer/src/studio.css, which
main.tsx loads right after @radical/ui/styles.css.

The shared stylesheet goes from 156 to 85 KB, and so does the Hub's CSS bundle
(166 KB before this cleanup, 86 KB now). Every rule survives exactly once:
both files are ordered subsets of the original. No @Keyframes name is
defined twice, so moving the @Keyframes blocks cannot change which
definition wins.

Checked with computed styles, transitions and animations disabled, of about
29,500 elements across 12 Studio and Hub screens. Measured against the build
before the previous commit, they are identical, and two runs of the same
build agree exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 test files in apps/studio exercised only @radical/ui and
@radical/common: the diagram model, views, collapse, undo, sequences,
milestones, presentations, presenter mode, auto-fit, connections and the
hub round-trip. They move to packages/ui/tests and import the package's
modules by relative path. The package gets its own vitest config on its
test stubs.

They pass without Studio's document persistence installed, so they did not
depend on it. The 144 tests that stay in Studio cover documents, autosave,
AI, routing and the host bridge.

Type-checking the moved tests turned up two issues, fixed here: an
Array.prototype.at call (ES2022, above the package's target) and a partial
node passed to addNode, now cast explicitly. _group_collapse_debug.test.ts is
renamed to groupCollapse.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part 2 of docs/IMPROVEMENTS.md still listed work this branch finished
(dead components and CSS, TimeTravelBar, the CSS split, store tests,
persistence out of the store) and linked to pre-monorepo paths. It now
separates what is done from what is still open, with current paths and
sizes.

The dynamic-views appendix is removed. It documented
src/renderer/src/dynamicViews.ts and tests/dynamicViews.test.ts, which exist
in no branch of the repository; sequences cover that ground in the model.

Part 1 (functional ideas) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tomasz-zajac-oss
tomasz-zajac-oss merged commit e4a1019 into main Sep 29, 2026
7 checks passed
@tomasz-zajac-oss
tomasz-zajac-oss deleted the refactor/monorepo-workspaces branch September 29, 2026 09:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant