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:
2026-04-29 00:42:18 +02:00
parent 2052a280b1
commit 384cbb4ae9
12 changed files with 1375 additions and 229 deletions

View File

@@ -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;
}
}