Skip to content

Monorepo: browser-safe @sysprom/core + Vite viewer on GitHub Pages#40

Open
Mearman wants to merge 28 commits into
mainfrom
feat/monorepo-web-viewer
Open

Monorepo: browser-safe @sysprom/core + Vite viewer on GitHub Pages#40
Mearman wants to merge 28 commits into
mainfrom
feat/monorepo-web-viewer

Conversation

@Mearman

@Mearman Mearman commented Jun 24, 2026

Copy link
Copy Markdown
Member

Adds a GitHub Pages-hosted viewer for SysProM documents, structured as a pnpm workspace with a browser-safe core.

What's here

  • @sysprom/core (packages/core): the pure library extracted out of sysprom — schema, operations, graph/infer, pure conversions. No node:* imports, so it bundles for the browser.
  • sysprom (root): now compiled with tsdown, which inlines @sysprom/core and externalises the real deps. The published package is self-contained (npm install sysprom still resolves; @sysprom/core is a workspace devDep, not a runtime dep). CLI/MCP bins and the programmatic API are unchanged.
  • packages/web: a Vite + React + vanilla-extract + Radix viewer. Loads .SysProM.json/.md, shows stats, nodes, relationships, an interactive Cytoscape graph (ELK layered / fCoSE overview / breadthfirst trace, node-select with neighbourhood highlight, type + status filters), trace, and JSON export. Mermaid is kept for a "View as Mermaid" export.
  • CI: builds the viewer and deploys it at the Pages root, with the TypeDoc API docs at /docs.
  • schema: SysProMDocument and Node are now named recursive interfaces, so the recursive subsystem type no longer serialises as any in the emitted declarations.

Run it

  • pnpm install
  • pnpm --filter web dev (or pnpm --filter web build && pnpm --filter web preview)
  • merge to main to deploy

Notes

  • tsdown bundles core into sysprom rather than publishing a second package, keeping the published contract and release pipeline unchanged.
  • elkjs is EPL-2.0; it lives only in the web bundle (lazy-loaded for the Layered layout), not in any published package.
  • Mermaid and ELK are lazy-loaded; the initial bundle is the graph view only.

Mearman added 28 commits June 24, 2026 13:05
DEC52 adopts a pnpm workspace with a browser-safe @sysprom/core and an
unpublished Vite viewer on GitHub Pages; CHG50 tracks the conversion;
INV33 guards the published sysprom package contract through the split.
Establishes a pnpm workspace with packages/* and adds the @sysprom/core
package skeleton: package.json (private, browser-safe, workspace dep on
zod matching root), tsconfig split (build config emits src only; base
config typechecks src+tests with node types), and a shared tsconfig.base.json.

Root tsconfig now extends the base and adds a path mapping so the root
type-checker resolves @sysprom/core to its source (avoids recursive-type
elision mismatches between source and compiled .d.ts). Turbo tasks wire
root typecheck/test/build to depend on @sysprom/core#build.
Moves the pure (no Node filesystem) library logic into packages/core:
schema, text, canonical-json, lifecycle-state, endpoint-types, utils,
speckit/plan, and all operations except the fs-backed sync and
speckit-{import,export,diff,sync} which stay at root. speckit-export
remains at root because generate.ts transitively imports node:fs.

Conversion split: json-to-md and md-to-json are split so the pure
rendering/parsing lives in core (jsonToMarkdownSingle, renderMultiDoc,
markdownSingleToJson, parseMultiDoc) and the fs wrappers
(jsonToMarkdownMultiDoc, markdownMultiDocToJson, jsonToMarkdown,
markdownToJson) stay at root as thin delegates over the pure helpers.

Root src/index.ts re-exports * from @sysprom/core plus the fs wrappers,
io, sync (syncDocumentsOp, detectChanges), and Spec-Kit entrypoints.
The public export surface is unchanged (additions only; no name removed).
All root modules referencing moved files now import from @sysprom/core;
the root tsconfig adds a path mapping so the type-checker resolves
@sysprom/core to its source (avoids recursive-type elision mismatches
between source and compiled .d.ts).

Eslint config extends the existing src/ rule relaxations and test
overrides to packages/core so the moved code keeps its lint posture.
Turbo wires root typecheck/test/build to depend on @sysprom/core#build.
Moves the 11 pure-logic test suites (graph, query, infer-{completeness,
derived,impact,lifecycle}, mutate, node-id, plan-task-lifecycle,
safe-removal, safe-removal-phase2) into packages/core/tests/ where they
type-check and run against the core package directly. These tests have
no node:fs dependencies; they use only node:test/node:assert.

Root tests that import moved symbols (schema types, canonicalise,
operations) now import those from @sysprom/core. Tests touching the
filesystem or CLI (roundtrip, json-to-md, sync-*, add-cli, coverage,
speckit-*, temporal, validate, stats, safety-guards) stay at root and
import pure symbols from @sysprom/core while keeping fs-backed imports
from the root src wrappers.
…sdown

Switch the root compile from tsc to tsdown so @sysprom/core is inlined
into sysprom's dist while zod, commander, picocolors and the MCP SDK stay
external. The published package no longer declares @sysprom/core (moved to
devDependencies), so npm install sysprom resolves without a private dep.
tsdown also emits a single self-contained index.d.mts (core types inlined)
and shebangs the CLI/MCP bins.

Paths, exports, files and bin entries move from dist/src/*.js to the tsdown
dist/*.mjs layout. allowDefaultProject gains tsdown.config.ts. Also bumps
typescript 6.0.2 -> 6.0.3 (latest; recursive Zod type elision is unchanged).
Add packages/web as a workspace package: Vite 6, React 19, TypeScript,
vanilla-extract for zero-runtime CSS, Radix UI primitives for headless
tabs. Configured for GitHub Pages root deployment (base: '/'). All
domain logic comes from @sysprom/core (workspace:*); nothing imports
from the root sysprom package or node:* builtins.
Lean, readable design system using vanilla-extract createTheme: a
muted palette, monospace badges for IDs/types, and a tabbed layout
shell. The theme class is applied at the root wrapper.
Support three load paths: single .SysProM.json (parsed with
SysProMDocument.parse), single .SysProM.md (markdownSingleToJson), and
multiple .md files (parseMultiDoc). Validation issues from validateOp
are surfaced in an error panel. Includes drag-and-drop, file picker,
'Load sample' (fetches the repo's self-describing document from
public/), and JSON export.
Stats tab shows node/relationship counts, subsystem depth, views, and
external references from statsOp. Nodes tab uses queryNodesOp with
type and text filters. Relationships tab uses queryRelationshipsOp
with type/from/to filters.
Graphs tab renders the four diagram kinds (relationship, refinement,
decision, dependency) from graphOp output via mermaid.render. Includes
a diagram selector and friendly/compact label toggle. Per-diagram
layout defaults match the CLI (TD for relationship/refinement/decision,
LR for dependency). Trace tab uses traceFromNodeOp to render the
refinement tree from a selected node.
The site job builds the web app (via turbo, which builds @sysprom/core
first), generates the TypeDoc HTML, places it under packages/web/dist/docs,
and uploads packages/web/dist as the Pages artifact. The viewer is served
at the site root and the API docs at /docs; deploy runs on push to main.
…to stop declaration elision

tsc cannot serialise Zod's anonymous inferred mutually-recursive types into
.d.ts files, so it emitted `/*elided*/ any` for the `subsystem` and `nodes`
fields of the recursive schemas. Downstream packages inherited the elision,
losing type safety on the two most central types.

Declare `SysProMDocument` and `Node` as explicit named recursive interfaces
(the public type contract) and annotate the recursive schema consts as
`z.ZodType<NamedType>` so every downstream use — the `.is()` guard,
`z.array(...)`, `.optional()`, `defineSchema(...)` — resolves to the clean
named interface instead of the unserialisable anonymous type.

The raw schema expressions (`_rawSysProMDocumentSchema`, `NodeBase`) are left
unannotated so their true inferred output can be captured for a compile-time
drift check against the interfaces. The check uses `null satisfies Cond ? null
: never` in four directions (forward and backward for each type); any field
type or optionality divergence resolves the conditional to `never` and
`null satisfies never` fails the build.

`Node` carries the `[x: string]: unknown` index signature, mirroring Zod 4's
inferred output for `z.looseObject`; `SysProMDocument` (from `z.object`) has
no index signature, matching the strict object output.

Runtime behaviour is unchanged: `schema.json` regenerates byte-identically,
the `.is()` guards parse the same inputs, and `spm validate` reports the
self-describing document valid.
Asserts that the built `dist/schema.d.ts` contains no `/*elided*/` markers.
The test skips when dist is absent (so `pnpm test` without a build is not
blocked) and runs in CI where build precedes test.
…outs

Replace the static Mermaid-only Graphs tab with an interactive Cytoscape.js
graph that builds elements directly from doc.nodes and doc.relationships
(no Mermaid string parsing). Three layout modes: ELK layered (hierarchical,
mapping SysProM abstraction layers), fcose (force-directed overview), and
Cytoscape breadthfirst (trace from a selected node).

Node click highlights the neighbourhood (dimming non-neighbours) and shows a
details side-panel with id, type, name, status, lifecycle, description, and
incoming/outgoing relationship counts. Subsystem-bearing nodes get a double
border and the panel lists their contents. Type and status checkboxes filter
the graph live. A legend maps colours and shapes. Mermaid rendering is kept
as a secondary 'View as Mermaid' toggle for export and embedding.

The Cytoscape instance is created once on mount and updated via the API
(elements, visibility, layout) rather than being torn down on each render,
keeping the 223-node sample document responsive.

Dependencies: cytoscape 3.34.0, cytoscape-fcose 2.2.0, elkjs 0.11.1
(all published >=7 days ago).
Cytoscape renders on a canvas and cannot resolve CSS custom properties
(var(--...)) produced by vanilla-extract's createTheme. Extract the raw
token values into a shared themeTokens object consumed by both the
vanilla-extract theme (for DOM) and the Cytoscape stylesheet (for canvas),
keeping a single source of truth. Also remove the custom wheelSensitivity
to silence Cytoscape's cross-hardware warning.
Extract MermaidView into its own component loaded via React.lazy +
Suspense, and replace the static elkjs import with a dynamic
await import() inside computeElkPositions. Both heavy dependencies
now ship as separate lazy chunks fetched only when the user
activates the Mermaid view or the Layered (ELK) layout.

The default layout remains Overview (fCoSE) so the first paint
needs neither chunk.
The vanilla-extract compiler imports @vanilla-extract/css/adapter when
transforming .css.ts files. It was only transitive (via the vite-plugin's
compiler), which resolves under local hoisting but not in CI's stricter
pnpm resolution. Declaring it directly on packages/web fixes the
"Cannot find package '@vanilla-extract/css/adapter'" build-site failure.
Replace the single ELK layered layout (broken in-browser: elkjs worker
404s under Vite) with five switchable layouts driven by the relationship
backbone:

- Refinement hierarchy (default): dagre top-down DAG over the structural
  backbone edges (refines, part_of, realises, implements, precedes,
  must_follow). Cross-cutting edges are hidden during layout and restored
  as overlays so they never drive the ranking. Direction emerges from the
  model, not a node-type taxonomy.
- Emergent topology: fCoSE over the same backbone edges; clusters surface
  from connectivity with no imposed direction.
- By subsystem: fCoSE compound layout grouping nodes by the recursive
  subsystem they belong to (flattened from node.subsystem.nodes).
- Overview: fCoSE over all edges (unchanged).
- Trace: breadthfirst from the selected node (unchanged).

elkjs is removed; cytoscape-dagre replaces it (main-thread, no worker,
bundles dagre). The columnar abstraction-layer rank code is removed.

Also fixes a pre-existing selection bug: the core tap handler fired on
every tap (including node taps) and immediately cleared the selection the
node-tap handler had just set, so Trace (which needs a selected node) was
unreachable in the browser. Guard the background handler on the tap
target being the core.
…ter instead of gridding

The Emergent topology layout previously ran fCoSE on only the backbone
edges (refines, part_of, realises, implements, precedes, must_follow),
hiding the entire cross-cutting set during layout. Those cross-cutting
edges are precisely what connect decisions, changes, invariants,
principles, and policies to the rest of the model, so the nodes with
only governance/impact edges had no edges in the layout subgraph and
were packed by fCoSE into a disconnected grid block at the bottom of
the Emergent view.

Define EMERGENT_REL_TYPES = backbone plus {affects, must_preserve,
depends_on, constrained_by, governed_by, modifies, produces} — every
relationship type except supersedes (pure historical replacement that
adds noise without contributing to clustering). The Emergent layout
now ranks on this broader set, so decisions cluster near the
capabilities they affect, invariants near the nodes they constrain,
and changes near the nodes they modify. Only supersedes edges are
hidden during layout and restored as overlays.

Parameterise the layout runner: Refinement still uses the strict
backbone (clean top-down tree via dagre), Emergent uses the emergent
edge set (fCoSE). Tune fCoSE for the denser graph: higher node
repulsion, longer ideal edges, packComponents disabled.
traceRootId follows the current selection (GraphsTab passes
traceRootId={selectedId}), so listing it in the shared layout effect's
dependencies re-ran whichever layout was active every time a node was
clicked — e.g. Emergent topology re-laid-out on each selection. Split the
effect: the structural layouts (refinement, emergent, subsystem, overview)
depend only on layout/doc/useSubsystemElements, and a dedicated trace-only
effect depends on traceRootId. Selecting a node now only highlights its
neighbourhood; only Trace re-runs when its root changes.
…quare

Disconnected nodes (no relationship edges in the layout subgraph) were
being tiled into a dense grid block by fCoSE and dagre. Two changes:

- After the primary layout settles, nodes with zero incident layout
  edges are repositioned into a loose horizontal arc along the bottom
  periphery of the connected mass, reading as an unlinked cluster.
- View nodes (which link to members via the includes data field, not
  relationships) are repositioned to the centroid of their visible
  members after layout, placing them among their group rather than in
  the orphan block.

fCoSE tile/packComponents disabled on Emergent, Subsystem, and Overview
layouts since orphan placement now handles disconnected nodes explicitly.

Applies to all structural layouts: Refinement (dagre), Emergent (fCoSE),
Subsystem (fCoSE compound), and Overview (fCoSE).
Tests identifyOrphans, boundingBox, centroid, and placeOrphansInArc.
Written to the project's strict lint rules: void on each node:test
describe/it (they return thenable Test objects), assert.ok narrowing in
place of non-null assertions, localeCompare comparators on sorts.
Replace the single-row orphan arc with type-grouped peripheral clusters.
Orphans are bucketed by SysProM node type, each bucket laid out as a
compact grid and anchored to a distinct point on one of the four sides of
the laid-out bounding box, so disconnected nodes read as intentional
labelled sets ('Invariants', 'Decisions', ...) around the rim rather than
a horizontal line or dense square.

Cluster labels are rendered as an HTML overlay (not canvas text, which is
unreadable at whole-graph zoom). The overlay is synced to pan/zoom via a
viewport tick; chip positions are derived from each label's model
coordinates using cy.zoom()/pan().

Also enforce a hard node-clearance invariant: after every layout settles
(including the orphan clusters and view-centroid placements) an iterative
pairwise separation pass pushes apart any nodes closer than
MIN_NODE_CLEARANCE (derived from the 28px node size). fCoSE nodeSeparation
and dagre nodeSep are set to the same value so the layout itself starts
near-clearance-respecting.

Tune the Emergent fCoSE options (lower edgeElasticity, longer
idealEdgeLength, more numIter, higher nodeRepulsion) to reduce edge
crossings on the broader emergent edge set.

Views with visible includes members still move to their members' centroid;
views with no visible members now fall through to orphan grouping under
their own type.
Replace the placeOrphansInArc tests with coverage for the new pure helpers
(buildOrphanGroups, placeOrphansByType, orphanLabelId) and add a suite for
enforceClearance: no-op on small inputs, pairwise separation to
MIN_NODE_CLEARANCE, fixed-node handling, and a tight 2D cluster converging
without overlap.
Add an ELK Layered layout option alongside the existing five layout modes.
ELK's layered algorithm assigns nodes to discrete layers, minimises edge
crossings (LAYER_SWEEP strategy), and routes edges orthogonally — so edges
go around nodes rather than through them.

The default elkjs entry spawns a web worker that 404s under Vite. Instead
the bundled build (elkjs/lib/elk.bundled.js) runs the layout engine
synchronously on the main thread, no worker required. It is lazy-loaded via
dynamic import() so it lands in its own Vite chunk, fetched only when the
ELK Layered layout is activated.

ELK's orthogonal edge routes are applied to Cytoscape edges as segments
curve-style control points (segment-distances / segment-weights), computed
from each edge's bend points relative to the source-target line. The
elk-routed stylesheet class enables segments mode per-edge only when ELK
routing data is present.

After positions settle, the same post-layout passes run as the other
layouts: placeOrphansAfterLayout (type-grouped peripheral clusters +
view centroids) and enforceClearanceOnCytoscape (minimum node clearance).

Pure helper functions (buildElkGraph, processElkResult, computeSegments)
are unit-tested without a browser.
The elkjs bundled build is a UMD module. Under Vite's production build,
dynamic import() wraps the UMD inner modules in an 'e' namespace, so the
constructor is at mod.e.default rather than mod.default. The loadElk
function now handles all known export shapes (ESM default, Vite UMD
wrapper, and direct global) using type guards instead of assertions.

Also adds an assert.ok guard in the layout options test to narrow the
optional layoutOptions field before property access.
The preset layout's positions callback receives a NodeSingular at runtime
(its .d.ts types the param as a string id), so the Map-by-id lookup missed
every node and returned {0,0} for all 223 — collapsing the ELK view into a
single overlapping blob with hundreds of invalid-endpoint warnings. Set
each node's position directly by id instead. The layout now spreads
correctly; a residual set of dense-cluster edges still warn (clearance
can't fully separate the change->decision bipartite cluster).
Pure segment-intersection count of edge crossings for a laid-out graph
(positions + edges -> number of properly-intersecting edge pairs, excluding
pairs that share an endpoint). O(E^2) pairwise, fine for the document's edge
count. Lets any layout's crossing count be measured objectively rather than
eyeballed, and to compare against the graph's topological crossing-number
floor.
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