MVP M5 (in-memory): bidirectional sync via ModelOp + applyOps + ModelStore
Both canvases now share a canonical SysMLModel through React context.
Renames in the diagram inspector ripple to every chip in the narrative
referencing the same refId, and double-clicking a chip emits an update op
that re-renders the diagram block. Validation re-runs on every successful
apply. State is in-memory; M5.9 adds Postgres persistence.
apps/web/lib/sync (new)
- ops.ts: ModelOp alphabet (18 op kinds across block / property /
association / constraint / requirement / relation, add/update/remove for
each). tempId() helper + per-kind constructors.
- applyOps.ts: pure (model, ops) → { model, idMapping, errors, applied }
reducer. Atomic per batch. Cascades on remove-block (drops incident
associations + constraint applies-to + requirement satisfiers). tempId
resolution rewrites to canonical ids on duplicate-id collision.
- ModelStore.tsx: React provider exposing { model, apply, issues,
issuesByElement }. Validation memoized on every model change.
Editor refactor
- EditorShell wraps in ModelStoreProvider. Initial model derived from
fixture (+ optional ?break= corruptions). Validation now lives in the
store, not duplicated here.
- LeftRail consumes useModel(): Model section lists real blocks +
constraints (sorted by kind), Requirements section lists real
requirements with traced/untraced status from r.relations.
Diagram refactor (the tricky piece)
- React Flow now owns ephemeral state via useNodesState / useEdgesState.
Positions, drag-in-progress, selection are all RF-internal.
- Model → RF: a useEffect runs on model change, applies targeted setNodes
updates only for elements whose semantic data (label, kind, properties)
changed. Object identity preserved for unchanged nodes — fixes the
"re-render storm on drag" + RF measurement-cache loss.
- RF → Model: onNodesChange / onEdgesChange / onConnect / onDrop emit
ops via useApply(). Constraint-applies edges decompose into
updateConstraint ops. deleteKeyCode={[Backspace, Delete]}.
- onNodesChange now handles type:'remove' too (was missing — that's why
selecting a block + Del removed only the edges, leaving the block).
Chip refactor
- ChipView resolves displayed label from useModel() via refId lookup
(block / requirement / association / property). Double-click chip →
inline rename input → emit update-{block,requirement,association} op.
All other chips with the same refId update on the next render.
- Slash-menu inserted chips have refId=null and skip the rename
affordance (until M6 wires real model element resolution).
Removed obsolete components/diagram-canvas/fixtureToFlow.ts; replaced
with modelToFlow.ts. Bumped CSS for the chip-rename input.
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
// The dual-canvas workspace shell.
|
||||
// Composes TopBar / SocratesDock / LeftRail / TextCanvas / DiagramCanvas / StatusBar.
|
||||
// M4 adds: SysML model derivation + validation, IssuesPanel, optional ?break=...
|
||||
// query param to demonstrate the validator surfacing rule violations.
|
||||
// M5: state lives in ModelStoreProvider; both canvases consume the canonical
|
||||
// SysMLModel and emit ModelOps back through useApply().
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { LeftRail } from "./LeftRail";
|
||||
@@ -18,8 +17,8 @@ import { SocratesDock, type Density, type SocratesPresence } from "../socrates/S
|
||||
import type { MarkupStyle } from "../text-canvas/Chip";
|
||||
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
||||
import { fromFixture } from "../../lib/sysml/fromFixture";
|
||||
import { validate, type ValidationIssue } from "../../lib/sysml/validate";
|
||||
import { applyBreaks, BREAKS, type BreakName } from "../../lib/sysml/breaks";
|
||||
import { ModelStoreProvider, useModelStore } from "../../lib/sync/ModelStore";
|
||||
|
||||
interface EditorShellProps {
|
||||
data: FixtureData;
|
||||
@@ -29,42 +28,62 @@ interface EditorShellProps {
|
||||
presence?: SocratesPresence;
|
||||
}
|
||||
|
||||
export function EditorShell({
|
||||
export function EditorShell(props: EditorShellProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<EditorShellInner {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorShellInner({
|
||||
data,
|
||||
density = "comfortable",
|
||||
markupStyle = "color",
|
||||
diagramStyle = "softened",
|
||||
presence = "default",
|
||||
}: EditorShellProps) {
|
||||
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Parse `?break=S1-dangling,M2-cycle&...` into a list of named corruptions.
|
||||
const breaks = useMemo<BreakName[]>(() => {
|
||||
const raw = searchParams?.get("break") ?? "";
|
||||
if (!raw) return [];
|
||||
return raw
|
||||
.split(",")
|
||||
.map(s => s.trim())
|
||||
.filter((s): s is BreakName => s in BREAKS);
|
||||
return raw.split(",").map(s => s.trim()).filter((s): s is BreakName => s in BREAKS);
|
||||
}, [searchParams]);
|
||||
|
||||
// Derive the SysML model from the fixture, optionally apply breaks, validate.
|
||||
const { issues, issuesByElement } = useMemo(() => {
|
||||
const baseModel = fromFixture(data);
|
||||
const broken = breaks.length > 0 ? applyBreaks(baseModel, breaks) : baseModel;
|
||||
const issues = validate(broken);
|
||||
const issuesByElement = new Map<string, ValidationIssue[]>();
|
||||
for (const i of issues) {
|
||||
const key = anchorKey(i.anchor);
|
||||
if (!key) continue;
|
||||
const prev = issuesByElement.get(key) ?? [];
|
||||
prev.push(i);
|
||||
issuesByElement.set(key, prev);
|
||||
}
|
||||
return { issues, issuesByElement };
|
||||
const initialModel = useMemo(() => {
|
||||
const base = fromFixture(data);
|
||||
return breaks.length > 0 ? applyBreaks(base, breaks) : base;
|
||||
}, [data, breaks]);
|
||||
|
||||
return (
|
||||
<ModelStoreProvider initialModel={initialModel}>
|
||||
<ShellBody
|
||||
data={data}
|
||||
density={density}
|
||||
markupStyle={markupStyle}
|
||||
diagramStyle={diagramStyle}
|
||||
presence={presence}
|
||||
breaks={breaks}
|
||||
/>
|
||||
</ModelStoreProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ShellBody({
|
||||
data,
|
||||
density,
|
||||
markupStyle,
|
||||
diagramStyle,
|
||||
presence,
|
||||
breaks,
|
||||
}: Required<Omit<EditorShellProps, "data">> & { data: FixtureData; breaks: BreakName[] }) {
|
||||
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
|
||||
const { model, issues, issuesByElement } = useModelStore();
|
||||
|
||||
const stats = `SysML · ${model.blocks.length} blocks · ${model.associations.length} associations · ${model.constraints.length} constraints`;
|
||||
const subtitle = breaks.length > 0 ? `${stats} · breaks active: ${breaks.join(", ")}` : stats;
|
||||
|
||||
return (
|
||||
<div className={`shell shell-density-${density} shell-presence-${presence}`}>
|
||||
<TopBar data={data} />
|
||||
@@ -97,9 +116,7 @@ export function EditorShell({
|
||||
<section className="canvas canvas-diagram">
|
||||
<CanvasHeader
|
||||
title="Model"
|
||||
subtitle={breaks.length > 0
|
||||
? `SysML · breaks active: ${breaks.join(", ")}`
|
||||
: "SysML · 6 blocks · 6 associations · 1 constraint"}
|
||||
subtitle={subtitle}
|
||||
right={
|
||||
<div className="canvas-actions">
|
||||
<span className="canvas-mode-pill">Fit</span>
|
||||
@@ -128,17 +145,3 @@ export function EditorShell({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Map an issue's anchor to a single string key matching either a block id or
|
||||
* a synthetic key (`assoc:a1`, `req:req-001`, `constraint:ferpa`). The UI uses
|
||||
* block ids most often, so block anchors return the bare id. */
|
||||
function anchorKey(anchor: ValidationIssue["anchor"]): string | null {
|
||||
switch (anchor.kind) {
|
||||
case "block": return anchor.id;
|
||||
case "association": return `assoc:${anchor.id}`;
|
||||
case "constraint": return `constraint:${anchor.id}`;
|
||||
case "requirement": return `req:${anchor.id}`;
|
||||
case "property": return anchor.blockId;
|
||||
case "model": return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
// Outline / Model / Requirements sections, each independently collapsible.
|
||||
// The whole rail can also collapse to a 36px vertical strip.
|
||||
// Ported from docs/design-source/socrata/project/editor-shell.jsx (LeftRail).
|
||||
//
|
||||
// M5: Model + Requirements sections read from the canonical SysMLModel via
|
||||
// useModelStore() so renames in either canvas reflect here immediately.
|
||||
// Outline section is still narrative-derived and uses the fixture (M6 will
|
||||
// migrate it to the live ProseMirror outline).
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
||||
import type { ValidationIssue, Severity } from "../../lib/sysml/validate";
|
||||
import { useModel } from "../../lib/sync/ModelStore";
|
||||
|
||||
interface LeftRailProps {
|
||||
data: FixtureData;
|
||||
@@ -29,10 +34,11 @@ function IssueDot({ severity, title }: { severity: Severity | null; title?: stri
|
||||
return <span className={`rail-issue-dot rail-issue-dot-${severity}`} title={title} />;
|
||||
}
|
||||
|
||||
export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement }: LeftRailProps) {
|
||||
export function LeftRail({ focusBlockId, setFocusBlockId, issuesByElement }: LeftRailProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [open, setOpen] = useState({ outline: true, model: true, requirements: true });
|
||||
const toggle = (k: keyof typeof open) => setOpen(s => ({ ...s, [k]: !s[k] }));
|
||||
const model = useModel();
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
@@ -47,13 +53,39 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement
|
||||
</button>
|
||||
<div className="rail-collapsed-stack">
|
||||
<span className="rail-collapsed-tag" title="Outline">OUT</span>
|
||||
<span className="rail-collapsed-tag" title="Model · 6 blocks">MOD</span>
|
||||
<span className="rail-collapsed-tag" title={`Model · ${model.blocks.length + model.constraints.length} elements`}>MOD</span>
|
||||
<span className="rail-collapsed-tag" title="Requirements">REQ</span>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// Combine blocks + constraints in the Model section, sorted by kind for a
|
||||
// predictable order: system → block → actor → constraint.
|
||||
const kindRank: Record<string, number> = { system: 0, block: 1, actor: 2, constraint: 3 };
|
||||
const modelEntries: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "block" | "actor" | "constraint" | "system";
|
||||
propertyCount: number;
|
||||
}> = [
|
||||
...model.blocks.map(b => ({
|
||||
id: b.id,
|
||||
label: b.label,
|
||||
kind: b.kind,
|
||||
propertyCount: b.properties.length,
|
||||
})),
|
||||
...model.constraints.map(c => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
kind: "constraint" as const,
|
||||
propertyCount: 0,
|
||||
})),
|
||||
].sort((a, b) => {
|
||||
const r = (kindRank[a.kind] ?? 99) - (kindRank[b.kind] ?? 99);
|
||||
return r !== 0 ? r : a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return (
|
||||
<nav className="leftrail">
|
||||
<div className="rail-section">
|
||||
@@ -96,7 +128,7 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement
|
||||
</button>
|
||||
{open.model && (
|
||||
<ul className="rail-list rail-blocks">
|
||||
{data.blocks.map(b => {
|
||||
{modelEntries.map(b => {
|
||||
const sev = maxSeverityForKey(issuesByElement, b.id);
|
||||
const tooltip = issuesByElement?.get(b.id)?.map(i => `${i.code}: ${i.message}`).join("\n");
|
||||
return (
|
||||
@@ -105,19 +137,23 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement
|
||||
className={`rail-block rail-${b.kind} ${focusBlockId === b.id ? "rail-block-active" : ""}`}
|
||||
onMouseEnter={() => setFocusBlockId(b.id)}
|
||||
onMouseLeave={() => setFocusBlockId(null)}
|
||||
onClick={() => setFocusBlockId(b.id)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<span className="rail-block-glyph">
|
||||
{b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : "▢"}
|
||||
{b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : b.kind === "system" ? "◎" : "▢"}
|
||||
</span>
|
||||
<span className="rail-block-label">{b.label}</span>
|
||||
<IssueDot severity={sev} title={tooltip} />
|
||||
<span
|
||||
className="rail-block-count"
|
||||
title={`${b.properties.length} ${b.properties.length === 1 ? "property" : "properties"}`}
|
||||
>
|
||||
<span className="rail-block-count-glyph">·</span>
|
||||
{b.properties.length}
|
||||
</span>
|
||||
{b.kind !== "constraint" && (
|
||||
<span
|
||||
className="rail-block-count"
|
||||
title={`${b.propertyCount} ${b.propertyCount === 1 ? "property" : "properties"}`}
|
||||
>
|
||||
<span className="rail-block-count-glyph">·</span>
|
||||
{b.propertyCount}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -132,18 +168,18 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement
|
||||
</button>
|
||||
{open.requirements && (
|
||||
<ul className="rail-list">
|
||||
{(["req-001", "req-002", "req-003"] as const).map(reqId => {
|
||||
const tag = reqId.toUpperCase().replace("-", "-");
|
||||
const sev = maxSeverityForKey(issuesByElement, `req:${reqId}`);
|
||||
const tooltip = issuesByElement?.get(`req:${reqId}`)?.map(i => `${i.code}: ${i.message}`).join("\n");
|
||||
const isUntraced = sev === "warning"; // T1 is the warning we surface here
|
||||
{model.requirements.map(r => {
|
||||
const key = `req:${r.id}`;
|
||||
const sev = maxSeverityForKey(issuesByElement, key);
|
||||
const tooltip = issuesByElement?.get(key)?.map(i => `${i.code}: ${i.message}`).join("\n");
|
||||
const traced = r.relations.some(rel => rel.kind === "satisfy");
|
||||
return (
|
||||
<li key={reqId} className="rail-req">
|
||||
<span className="req-tag">{tag.toUpperCase()}</span>
|
||||
<li key={r.id} className="rail-req">
|
||||
<span className="req-tag">{r.tag}</span>
|
||||
{sev ? (
|
||||
<IssueDot severity={sev} title={tooltip} />
|
||||
) : (
|
||||
<span className={`req-status ${isUntraced ? "req-untraced" : "req-traced"}`} />
|
||||
<span className={`req-status ${traced ? "req-traced" : "req-untraced"}`} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user