MVP M1–M3: visual port, TipTap text editor, React Flow diagram

apps/web — Next.js 16 + TypeScript + React 19 (no Tailwind)

M1 Visual port
- Manuscript theme + base styles ported verbatim from design-source
- 100dvh layout with internal scroll regions (rail / narrative / dock thread)
- TopBar, LeftRail (collapsible sections + collapsed strip), CanvasHeader,
  StatusBar, EditorShell composing the dual-canvas
- SocratesDock (subtle/default/prominent) with bubbles + numbered options
- Sigil (Σ + laurel) used across editor and seed screens
- SeedScreen with emerging-seed rail, mini-graph, confidence bar, thread,
  typing indicator, input row
- Routes: / (homepage), /editor/[projectId], /seed
- Aristotle fixture in lib/fixtures (ported from data.js)

M2 Real text editor
- TipTap (StarterKit + custom Chip atom inline node + ReactNodeViewRenderer)
- Slash-menu insertion via @tiptap/suggestion with arrow / number-key shortcuts
- ChipFocusContext bridges narrative ↔ diagram hover/selection across the
  TipTap render boundary
- fixtureToDoc converts the fixture narrative → ProseMirror JSON

M3 Real diagram
- React Flow (@xyflow/react) custom node (block / actor / constraint / system)
  matching prototype's softened look
- Custom edge with bezier path + label renderer (association / composition /
  constraint variants)
- Drag-create from a left-side palette via HTML5 DnD + screenToFlowPosition
- NodeInspector panel: edit kind / label / properties (or expression for
  constraints); delete cascades to incident edges
- Bidirectional focus highlighting between chips (narrative) and blocks
  (diagram)
- Removed redundant legend (stereotype labels on nodes serve same purpose)

State for M3 lives in component memory; persistence + bidirectional sync land
in M4–M5.
This commit is contained in:
2026-04-28 22:54:36 +02:00
parent f1c4566576
commit a0566ce64c
38 changed files with 7988 additions and 0 deletions

View File

@@ -0,0 +1,203 @@
// React Flow-backed diagram canvas (M3).
// - Custom block/actor/constraint/system nodes
// - Custom edges (association/composition/constraint)
// - Drag-create from the left palette
// - Click-edit via the right inspector
// - Drag handles between nodes to connect
//
// State is in-memory for M3; M5 swaps it for the bidirectional sync engine.
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ReactFlow,
ReactFlowProvider,
Background,
BackgroundVariant,
Controls,
MarkerType,
applyNodeChanges,
applyEdgeChanges,
addEdge,
useReactFlow,
type Node,
type Edge,
type NodeChange,
type EdgeChange,
type Connection,
type NodeMouseHandler,
} from "@xyflow/react";
import { BlockNode, type BlockNodeData } from "./nodes/BlockNode";
import { SysmlEdge, type SysmlEdgeData } from "./edges/SysmlEdge";
import { Palette } from "./Palette";
import { NodeInspector } from "./NodeInspector";
import { fixtureToFlow } from "./fixtureToFlow";
import type { Density } from "../socrates/SocratesDock";
import type { FixtureData, AssociationKind, BlockKind } from "../../lib/fixtures/aristotle";
export type DiagramVariant = "softened" | "formal" | "graph";
interface DiagramCanvasProps {
data: FixtureData;
density?: Density;
variant?: DiagramVariant;
focusBlockId: string | null;
onSelect?: (id: string | null) => void;
}
const nodeTypes = { sysmlBlock: BlockNode };
const edgeTypes = { sysml: SysmlEdge };
let nextNodeId = 1000;
let nextEdgeId = 1000;
function freshNodeId(): string { return `n${nextNodeId++}`; }
function freshEdgeId(): string { return `e${nextEdgeId++}`; }
export function DiagramCanvas(props: DiagramCanvasProps) {
return (
<ReactFlowProvider>
<DiagramInner {...props} />
</ReactFlowProvider>
);
}
function DiagramInner({ data, focusBlockId, onSelect }: DiagramCanvasProps) {
const initial = useMemo(() => fixtureToFlow(data), [data]);
const [nodes, setNodes] = useState<Node<BlockNodeData>[]>(initial.nodes);
const [edges, setEdges] = useState<Edge<SysmlEdgeData>[]>(initial.edges);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const { screenToFlowPosition } = useReactFlow();
const onNodesChange = useCallback(
(changes: NodeChange[]) => setNodes(ns => applyNodeChanges(changes, ns) as Node<BlockNodeData>[]),
[]
);
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => setEdges(es => applyEdgeChanges(changes, es) as Edge<SysmlEdgeData>[]),
[]
);
const onConnect = useCallback((connection: Connection) => {
const newEdge: Edge<SysmlEdgeData> = {
id: freshEdgeId(),
source: connection.source,
target: connection.target,
type: "sysml",
data: { label: "relates_to", kind: "association" satisfies AssociationKind },
markerEnd: { type: MarkerType.Arrow, color: "var(--edge)", width: 18, height: 18 },
};
setEdges(es => addEdge(newEdge, es) as Edge<SysmlEdgeData>[]);
}, []);
// Highlight focusBlockId from sibling components (e.g. narrative chip hover).
useEffect(() => {
setNodes(ns =>
ns.map(n => ({
...n,
selected: n.id === focusBlockId,
}))
);
}, [focusBlockId]);
const onNodeClick: NodeMouseHandler = useCallback(
(_event, node) => {
onSelect?.(node.id);
},
[onSelect]
);
const onPaneClick = useCallback(() => {
onSelect?.(null);
}, [onSelect]);
const selectedNode = nodes.find(n => n.id === focusBlockId);
// Drag-and-drop from the palette
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}, []);
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
const kindRaw = event.dataTransfer.getData("application/sysml-kind");
if (!kindRaw) return;
const kind = kindRaw as BlockKind | "system";
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
const id = freshNodeId();
const newNode: Node<BlockNodeData> = {
id,
type: "sysmlBlock",
position,
data: {
label: kind === "system"
? "System"
: kind === "actor"
? "Actor"
: kind === "constraint"
? "Constraint"
: "Block",
kind,
properties: kind === "constraint" ? [] : ["new_property"],
...(kind === "constraint" ? { expression: "{ }" } : {}),
},
};
setNodes(ns => [...ns, newNode]);
onSelect?.(id);
},
[onSelect, screenToFlowPosition]
);
function patchSelected(patch: Partial<BlockNodeData>) {
if (!focusBlockId) return;
setNodes(ns =>
ns.map(n => (n.id === focusBlockId ? { ...n, data: { ...(n.data as BlockNodeData), ...patch } } : n))
);
}
function deleteSelected() {
if (!focusBlockId) return;
setNodes(ns => ns.filter(n => n.id !== focusBlockId));
setEdges(es => es.filter(e => e.source !== focusBlockId && e.target !== focusBlockId));
onSelect?.(null);
}
return (
<div ref={wrapperRef} className="diagram-flow" onDragOver={onDragOver} onDrop={onDrop}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
fitView
fitViewOptions={{ padding: 0.15, includeHiddenNodes: false, maxZoom: 1.2 }}
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{
type: "sysml",
markerEnd: { type: MarkerType.Arrow, color: "var(--edge)", width: 18, height: 18 },
}}
>
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="var(--grid-dot)" />
<Controls showInteractive={false} position="bottom-right" />
</ReactFlow>
<Palette />
{selectedNode && (
<NodeInspector
nodeId={selectedNode.id}
data={selectedNode.data as BlockNodeData}
onChange={patchSelected}
onDelete={deleteSelected}
onClose={() => onSelect?.(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,140 @@
// Edit panel for the selected block — rename, change kind, add/remove properties, delete.
"use client";
import { useState, useEffect } from "react";
import type { BlockKind } from "../../lib/fixtures/aristotle";
import type { BlockNodeData } from "./nodes/BlockNode";
interface NodeInspectorProps {
nodeId: string;
data: BlockNodeData;
onChange: (patch: Partial<BlockNodeData>) => void;
onDelete: () => void;
onClose: () => void;
}
const KINDS: Array<BlockKind | "system"> = ["system", "block", "actor", "constraint"];
export function NodeInspector({ nodeId, data, onChange, onDelete, onClose }: NodeInspectorProps) {
// Local label state for smooth typing — push on blur
const [label, setLabel] = useState(data.label);
const [expression, setExpression] = useState(data.expression ?? "");
useEffect(() => {
setLabel(data.label);
setExpression(data.expression ?? "");
}, [nodeId, data.label, data.expression]);
function commitLabel() {
if (label.trim() && label !== data.label) onChange({ label: label.trim() });
}
function commitExpression() {
onChange({ expression });
}
function updateProp(idx: number, value: string) {
const next = [...data.properties];
next[idx] = value;
onChange({ properties: next });
}
function removeProp(idx: number) {
onChange({ properties: data.properties.filter((_, i) => i !== idx) });
}
function addProp() {
onChange({ properties: [...data.properties, "new_property"] });
}
return (
<div className="diagram-inspector" onClick={e => e.stopPropagation()}>
<div className="diagram-inspector-row">
<span className="diagram-inspector-label">Kind</span>
<select
className="diagram-inspector-select"
value={data.kind}
onChange={e => onChange({ kind: e.target.value as BlockKind | "system" })}
>
{KINDS.map(k => (
<option key={k} value={k}>«{k}»</option>
))}
</select>
</div>
<div className="diagram-inspector-row">
<span className="diagram-inspector-label">Label</span>
<input
className="diagram-inspector-input"
value={label}
onChange={e => setLabel(e.target.value)}
onBlur={commitLabel}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
/>
</div>
{data.kind === "constraint" ? (
<div className="diagram-inspector-row">
<span className="diagram-inspector-label">Expression</span>
<input
className="diagram-inspector-input"
value={expression}
onChange={e => setExpression(e.target.value)}
onBlur={commitExpression}
placeholder="{ tenancy = institutional }"
/>
</div>
) : (
<div className="diagram-inspector-row">
<span className="diagram-inspector-label">Properties</span>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{data.properties.map((p, i) => (
<div key={i} className="diagram-inspector-prop">
<input
defaultValue={p}
onBlur={e => updateProp(i, e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
/>
<button
className="diagram-inspector-prop-x"
onClick={() => removeProp(i)}
title="Remove property"
type="button"
>
×
</button>
</div>
))}
<button className="diagram-inspector-add" onClick={addProp} type="button">
+ property
</button>
</div>
</div>
)}
<div className="diagram-inspector-actions">
<button className="diagram-inspector-btn" onClick={onClose} type="button">
Close
</button>
<button
className="diagram-inspector-btn diagram-inspector-btn-danger"
onClick={onDelete}
type="button"
>
Delete
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
// Drag source for new blocks. Drop on the React Flow canvas to create.
"use client";
import type { BlockKind } from "../../lib/fixtures/aristotle";
interface PaletteItem {
kind: BlockKind | "system";
label: string;
glyph: string;
}
const ITEMS: PaletteItem[] = [
{ kind: "system", label: "System", glyph: "◎" },
{ kind: "block", label: "Block", glyph: "▢" },
{ kind: "actor", label: "Actor", glyph: "◐" },
{ kind: "constraint", label: "Constraint", glyph: "{}" },
];
export function Palette() {
function onDragStart(event: React.DragEvent, kind: string) {
event.dataTransfer.setData("application/sysml-kind", kind);
event.dataTransfer.effectAllowed = "move";
}
return (
<div className="diagram-palette">
<div className="diagram-palette-label">Add</div>
{ITEMS.map(item => (
<div
key={item.kind}
className="diagram-palette-item"
draggable
onDragStart={e => onDragStart(e, item.kind)}
title={`Drag to create a ${item.label}`}
>
<span className="diagram-palette-glyph">{item.glyph}</span>
<span>{item.label}</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,61 @@
// Custom edge that renders association / composition / constraint variants.
// - association: solid line with arrow
// - composition: solid line with diamond marker at the source side
// - constraint: dashed line, no arrow
"use client";
import { BaseEdge, EdgeLabelRenderer, getBezierPath, type EdgeProps } from "@xyflow/react";
import type { AssociationKind } from "../../../lib/fixtures/aristotle";
export interface SysmlEdgeData extends Record<string, unknown> {
label?: string;
kind: AssociationKind;
}
export function SysmlEdge(props: EdgeProps) {
const { sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, markerEnd, markerStart } = props;
const d = (data ?? {}) as SysmlEdgeData;
const kind = d.kind ?? "association";
const [edgePath, labelX, labelY] = getBezierPath({
sourceX, sourceY,
sourcePosition,
targetX, targetY,
targetPosition,
curvature: 0.25,
});
const isConstraint = kind === "constraint";
return (
<>
<BaseEdge
id={props.id}
path={edgePath}
style={{
stroke: "var(--edge)",
strokeWidth: 1,
strokeDasharray: isConstraint ? "4 4" : undefined,
opacity: 0.85,
}}
markerEnd={markerEnd}
markerStart={markerStart}
/>
{d.label && (
<EdgeLabelRenderer>
<div
className="sysml-edge-label"
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
}}
>
{d.label}
</div>
</EdgeLabelRenderer>
)}
</>
);
}

View File

@@ -0,0 +1,47 @@
// Convert FixtureData → React Flow nodes/edges.
// Positions in the fixture are normalized 0..1 against a 720×460 board; we map
// to absolute pixel coordinates so React Flow can render them.
import { MarkerType, type Edge, type Node } from "@xyflow/react";
import type { BlockNodeData } from "./nodes/BlockNode";
import type { SysmlEdgeData } from "./edges/SysmlEdge";
import type { FixtureData, AssociationKind } from "../../lib/fixtures/aristotle";
const BOARD_W = 720;
const BOARD_H = 460;
function markerForKind(kind: AssociationKind) {
if (kind === "composition") {
// React Flow doesn't ship a diamond marker; use the closed-arrow as the
// closest stock alternative. M3 ships this — a custom diamond can be
// added later if needed.
return { type: MarkerType.ArrowClosed, color: "var(--edge)", width: 18, height: 18 };
}
if (kind === "constraint") return undefined;
return { type: MarkerType.Arrow, color: "var(--edge)", width: 18, height: 18 };
}
export function fixtureToFlow(data: FixtureData): { nodes: Node<BlockNodeData>[]; edges: Edge<SysmlEdgeData>[] } {
const nodes: Node<BlockNodeData>[] = data.blocks.map(b => ({
id: b.id,
type: "sysmlBlock",
position: { x: b.x * BOARD_W, y: b.y * BOARD_H },
data: {
label: b.label,
kind: b.kind,
properties: [...b.properties],
...(b.kind === "constraint" ? { expression: "{ tenancy = institutional }" } : {}),
},
}));
const edges: Edge<SysmlEdgeData>[] = data.associations.map(a => ({
id: a.id,
source: a.from,
target: a.to,
type: "sysml",
data: { label: a.label, kind: a.kind },
markerEnd: markerForKind(a.kind),
}));
return { nodes, edges };
}

View File

@@ -0,0 +1,67 @@
// SysML block / actor / constraint rendered as a React Flow custom node.
// Drives the softened look from the prototype: stereotype label, divider, property compartment.
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import type { BlockKind } from "../../../lib/fixtures/aristotle";
export interface BlockNodeData extends Record<string, unknown> {
label: string;
kind: BlockKind | "system";
properties: string[];
/** For constraint kinds, optional one-line expression. */
expression?: string;
}
const STEREO: Record<string, string> = {
block: "block",
actor: "actor",
constraint: "constraint",
system: "system",
};
export function BlockNode({ data, selected }: NodeProps) {
const d = data as BlockNodeData;
const kind = d.kind ?? "block";
const isConstraint = kind === "constraint";
const cls = [
"sysml-node",
`sysml-node-${kind}`,
selected ? "sysml-node-selected" : "",
]
.filter(Boolean)
.join(" ");
return (
<div className={cls}>
<Handle type="target" position={Position.Left} />
<Handle type="target" position={Position.Top} />
<div className="sysml-node-stereo">«{STEREO[kind] ?? "block"}»</div>
<div className="sysml-node-title">{d.label}</div>
{!isConstraint && <div className="sysml-node-divider" />}
{!isConstraint ? (
<div className="sysml-node-props">
{d.properties.length === 0 ? (
<span className="sysml-node-prop-empty">no properties</span>
) : (
d.properties.slice(0, 6).map((p, i) => (
<span key={i} className="sysml-node-prop">· {p}</span>
))
)}
</div>
) : (
<div className="sysml-node-constraint-body">
{d.expression ?? "{ }"}
</div>
)}
<Handle type="source" position={Position.Right} />
<Handle type="source" position={Position.Bottom} />
</div>
);
}