Monorepo: browser-safe @sysprom/core + Vite viewer on GitHub Pages#40
Open
Mearman wants to merge 28 commits into
Open
Monorepo: browser-safe @sysprom/core + Vite viewer on GitHub Pages#40Mearman wants to merge 28 commits into
Mearman wants to merge 28 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a GitHub Pages-hosted viewer for SysProM documents, structured as a pnpm workspace with a browser-safe core.
What's here
sysprom— schema, operations, graph/infer, pure conversions. Nonode:*imports, so it bundles for the browser.@sysprom/coreand externalises the real deps. The published package is self-contained (npm install syspromstill resolves;@sysprom/coreis a workspace devDep, not a runtime dep). CLI/MCP bins and the programmatic API are unchanged..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./docs.SysProMDocumentandNodeare now named recursive interfaces, so the recursivesubsystemtype no longer serialises asanyin the emitted declarations.Run it
pnpm installpnpm --filter web dev(orpnpm --filter web build && pnpm --filter web preview)Notes
syspromrather than publishing a second package, keeping the published contract and release pipeline unchanged.elkjsis EPL-2.0; it lives only in the web bundle (lazy-loaded for the Layered layout), not in any published package.