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,71 @@
// Inline chip referencing a model element.
// Renders four visual styles via CSS (pill / color / underline / bracket).
// Ported from docs/design-source/socrata/project/text-canvas.jsx (Chip).
// M1: static. M2: becomes a TipTap node-view.
import type { ChipKind } from "../../lib/fixtures/aristotle";
export type MarkupStyle = "pill" | "color" | "underline" | "bracket";
const KIND_LABEL: Record<ChipKind, string> = {
block: "block",
property: "property",
association: "assoc",
requirement: "req",
};
function kindGlyph(kind: ChipKind): string {
switch (kind) {
case "block": return "▢";
case "property": return "·";
case "association": return "→";
case "requirement": return "§";
}
}
interface ChipProps {
kind: ChipKind;
id: string;
label: string;
markupStyle: MarkupStyle;
focused?: boolean;
onHover?: (id: string | null) => void;
onClick?: (id: string, kind: ChipKind) => void;
}
export function Chip({ kind, id, label, markupStyle, focused, onHover, onClick }: ChipProps) {
const cls = `chip chip-${kind} chip-style-${markupStyle}${focused ? " chip-focus" : ""}`;
const handlers = {
onMouseEnter: () => onHover?.(id),
onMouseLeave: () => onHover?.(null),
onClick: () => onClick?.(id, kind),
};
if (markupStyle === "bracket") {
return (
<span className={cls} {...handlers}>
<span className="chip-bracket">[</span>
<span className="chip-kind">{KIND_LABEL[kind]}:</span>
<span className="chip-label">{label}</span>
<span className="chip-bracket">]</span>
</span>
);
}
if (markupStyle === "underline") {
return (
<span className={cls} {...handlers}>
<span className="chip-glyph">{kindGlyph(kind)}</span>
<span className="chip-label">{label}</span>
</span>
);
}
// pill (default) and color both render as filled chips.
return (
<span className={cls} {...handlers}>
<span className="chip-glyph">{kindGlyph(kind)}</span>
<span className="chip-label">{label}</span>
</span>
);
}

View File

@@ -0,0 +1,82 @@
// TipTap node definition for inline model-element chips.
//
// M2: chips are inline, atomic, selectable. They carry { kind, refId, label }.
// In MVP M5 the label will be derived from a live model lookup keyed by refId;
// for M2 we store it directly on the node so renaming + persistence Just Work.
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import type { ChipKind } from "../../lib/fixtures/aristotle";
import { ChipView } from "./ChipView";
export interface ChipAttrs {
kind: ChipKind;
refId: string | null;
label: string;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
chip: {
insertChip: (attrs: ChipAttrs) => ReturnType;
};
}
}
export const ChipNode = Node.create({
name: "chip",
group: "inline",
inline: true,
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
kind: {
default: "block",
parseHTML: el => el.getAttribute("data-kind") ?? "block",
renderHTML: attrs => ({ "data-kind": attrs.kind }),
},
refId: {
default: null,
parseHTML: el => el.getAttribute("data-ref-id"),
renderHTML: attrs => (attrs.refId ? { "data-ref-id": attrs.refId } : {}),
},
label: {
default: "untitled",
parseHTML: el => el.getAttribute("data-label") ?? el.textContent ?? "untitled",
renderHTML: attrs => ({ "data-label": attrs.label }),
},
};
},
parseHTML() {
return [{ tag: "span[data-chip]" }];
},
renderHTML({ HTMLAttributes, node }) {
return [
"span",
mergeAttributes({ "data-chip": "" }, HTMLAttributes),
`${node.attrs.label}`,
];
},
addNodeView() {
return ReactNodeViewRenderer(ChipView);
},
addCommands() {
return {
insertChip:
(attrs: ChipAttrs) =>
({ chain }) =>
chain()
.insertContent({ type: this.name, attrs })
// Insert a trailing space so the caret leaves the chip cleanly
.insertContent(" ")
.run(),
};
},
});

View File

@@ -0,0 +1,73 @@
// React NodeView for the chip TipTap node.
// Renders identically to the static Chip via the same CSS classes.
"use client";
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import type { ChipKind } from "../../lib/fixtures/aristotle";
import type { MarkupStyle } from "./Chip";
import { useChipFocus } from "./FocusContext";
const KIND_LABEL: Record<ChipKind, string> = {
block: "block",
property: "property",
association: "assoc",
requirement: "req",
};
function kindGlyph(kind: ChipKind): string {
switch (kind) {
case "block": return "▢";
case "property": return "·";
case "association": return "→";
case "requirement": return "§";
}
}
export function ChipView({ node, selected, editor }: NodeViewProps) {
const kind = (node.attrs.kind as ChipKind) ?? "block";
const label = (node.attrs.label as string) ?? "untitled";
const refId = (node.attrs.refId as string | null) ?? null;
// Read the current markup style from the editor's storage; defaults to "color".
const markupStyle = ((editor.storage as Record<string, unknown>).markupStyle as MarkupStyle) ?? "color";
const { focusBlockId, setFocusBlockId } = useChipFocus();
const isFocused = (refId !== null && refId === focusBlockId) || selected;
const cls = `chip chip-${kind} chip-style-${markupStyle}${isFocused ? " chip-focus" : ""}`;
const handlers = refId
? {
onMouseEnter: () => setFocusBlockId(refId),
onMouseLeave: () => setFocusBlockId(null),
onClick: () => setFocusBlockId(refId),
}
: {};
if (markupStyle === "bracket") {
return (
<NodeViewWrapper as="span" className={cls} contentEditable={false} draggable={false} {...handlers}>
<span className="chip-bracket">[</span>
<span className="chip-kind">{KIND_LABEL[kind]}:</span>
<span className="chip-label">{label}</span>
<span className="chip-bracket">]</span>
</NodeViewWrapper>
);
}
if (markupStyle === "underline") {
return (
<NodeViewWrapper as="span" className={cls} contentEditable={false} draggable={false} {...handlers}>
<span className="chip-glyph">{kindGlyph(kind)}</span>
<span className="chip-label">{label}</span>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper as="span" className={cls} contentEditable={false} draggable={false} {...handlers}>
<span className="chip-glyph">{kindGlyph(kind)}</span>
<span className="chip-label">{label}</span>
</NodeViewWrapper>
);
}

View File

@@ -0,0 +1,23 @@
// React context for cross-canvas focus state.
//
// The chip node views are mounted via TipTap's ReactNodeViewRenderer; they
// can't reach into props on the editor instance, but they can use React
// Context (it propagates through any React tree, including TipTap's).
"use client";
import { createContext, useContext } from "react";
export interface ChipFocusValue {
focusBlockId: string | null;
setFocusBlockId: (id: string | null) => void;
}
export const ChipFocusContext = createContext<ChipFocusValue>({
focusBlockId: null,
setFocusBlockId: () => {},
});
export function useChipFocus(): ChipFocusValue {
return useContext(ChipFocusContext);
}

View File

@@ -0,0 +1,20 @@
// TipTap extension wrapping the slash-menu suggestion plugin.
"use client";
import { Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { slashSuggestion } from "./slashSuggestion";
export const SlashExtension = Extension.create({
name: "slashMenu",
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...slashSuggestion,
}),
];
},
});

View File

@@ -0,0 +1,110 @@
// Slash menu — appears when the user types `/`. Shows the four chip kinds.
// On selection, inserts a chip with a placeholder label that the user can
// then rename inline.
"use client";
import { useEffect, useImperativeHandle, useState, forwardRef } from "react";
import type { ChipKind } from "../../lib/fixtures/aristotle";
export interface SlashItem {
kind: ChipKind;
label: string;
hint: string;
glyph: string;
}
export const SLASH_ITEMS: SlashItem[] = [
{ kind: "block", label: "Block", hint: "an entity in the system", glyph: "▢" },
{ kind: "property", label: "Property", hint: "an attribute of a block", glyph: "·" },
{ kind: "association", label: "Association", hint: "a relationship", glyph: "→" },
{ kind: "requirement", label: "Requirement", hint: "a stated goal (REQ-NNN)", glyph: "§" },
];
export interface SlashMenuHandle {
onKeyDown: (event: KeyboardEvent) => boolean;
}
interface SlashMenuProps {
query: string;
command: (item: SlashItem) => void;
}
export const SlashMenu = forwardRef<SlashMenuHandle, SlashMenuProps>(function SlashMenu(
{ query, command },
ref
) {
const filtered = SLASH_ITEMS.filter(
item =>
query.length === 0 ||
item.kind.toLowerCase().startsWith(query.toLowerCase()) ||
item.label.toLowerCase().startsWith(query.toLowerCase())
);
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
setActiveIndex(0);
}, [query]);
useImperativeHandle(ref, () => ({
onKeyDown(event: KeyboardEvent) {
if (filtered.length === 0) return false;
if (event.key === "ArrowUp") {
setActiveIndex(prev => (prev - 1 + filtered.length) % filtered.length);
return true;
}
if (event.key === "ArrowDown") {
setActiveIndex(prev => (prev + 1) % filtered.length);
return true;
}
if (event.key === "Enter" || event.key === "Tab") {
const choice = filtered[activeIndex];
if (choice) {
command(choice);
return true;
}
}
// Number-key shortcut: 14 picks the corresponding item
const num = parseInt(event.key, 10);
if (!Number.isNaN(num) && num >= 1 && num <= filtered.length) {
const choice = filtered[num - 1];
if (choice) {
command(choice);
return true;
}
}
return false;
},
}));
if (filtered.length === 0) {
return (
<div className="slash-menu slash-menu-empty">
<span className="slash-menu-empty-text">no matches for {query}</span>
</div>
);
}
return (
<div className="slash-menu">
{filtered.map((item, idx) => (
<button
key={item.kind}
type="button"
className={`slash-menu-item ${idx === activeIndex ? "slash-menu-item-active" : ""}`}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={e => {
// mousedown so the click registers before the editor blurs
e.preventDefault();
command(item);
}}
>
<span className={`slash-menu-glyph slash-menu-glyph-${item.kind}`}>{item.glyph}</span>
<span className="slash-menu-label">{item.label}</span>
<span className="slash-menu-hint">{item.hint}</span>
<span className="slash-menu-key">{idx + 1}</span>
</button>
))}
</div>
);
});

View File

@@ -0,0 +1,94 @@
// TipTap-backed narrative editor.
// Renders the same prose surface as the static port, but typing actually works
// and chips can be inserted via the slash menu (`/block`, `/property`, etc.).
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useMemo } from "react";
import { ChipNode } from "./ChipNode";
import { SlashExtension } from "./SlashExtension";
import { ChipFocusContext } from "./FocusContext";
import { fixtureToDoc } from "./fixtureToDoc";
import type { Density } from "../socrates/SocratesDock";
import type { FixtureData } from "../../lib/fixtures/aristotle";
import type { MarkupStyle } from "./Chip";
interface TextCanvasProps {
data: FixtureData;
density: Density;
markupStyle: MarkupStyle;
focusBlockId: string | null;
setFocusBlockId: (id: string | null) => void;
}
export function TextCanvas({ data, density, markupStyle, focusBlockId, setFocusBlockId }: TextCanvasProps) {
const padY = density === "compact" ? 10 : 18;
const padX = density === "compact" ? 22 : 36;
const focusValue = useMemo(
() => ({ focusBlockId, setFocusBlockId }),
[focusBlockId, setFocusBlockId]
);
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2] },
// Drop features we don't need yet
codeBlock: false,
blockquote: false,
horizontalRule: false,
bulletList: false,
orderedList: false,
listItem: false,
strike: false,
code: false,
link: false,
}),
ChipNode,
SlashExtension,
],
content: fixtureToDoc(data),
editorProps: {
attributes: {
class: "text-canvas tiptap",
style: `padding: ${padY}px ${padX}px;`,
},
},
});
// Push the markup style into the editor so the ChipView NodeView can read it.
useEffect(() => {
if (!editor) return;
(editor.storage as Record<string, unknown>).markupStyle = markupStyle;
// Force a re-render of all chip node views so they pick up the new style.
editor.view.dispatch(editor.state.tr.setMeta("force-update", true));
}, [editor, markupStyle]);
if (!editor) {
return (
<div className="text-canvas" style={{ padding: `${padY}px ${padX}px` }}>
<div style={{ color: "var(--muted)", fontFamily: "var(--font-mono)", fontSize: 12 }}>
loading editor
</div>
</div>
);
}
return (
<ChipFocusContext.Provider value={focusValue}>
<EditorContent editor={editor} />
<div className="margin-note" style={{ margin: `0 ${padX}px ${padY}px ${padX}px`, maxWidth: 720 }}>
<span className="margin-note-glyph">Σ</span>
<span>
<span className="margin-note-who">Socrates · margin</span>
<span className="margin-note-text">
&ldquo;Refuses to produce solutions&rdquo; is a strong constraint. Have you decided what counts as a &ldquo;solution&rdquo; vs. a &ldquo;scaffold&rdquo;? This boundary will determine whether the refusal policy is enforceable.
</span>
</span>
</div>
</ChipFocusContext.Provider>
);
}

View File

@@ -0,0 +1,43 @@
// Convert the fixture's narrative shape to a ProseMirror document JSON.
// Used as the initial content for the TipTap editor.
import type { JSONContent } from "@tiptap/react";
import type { FixtureData, NarrativeNode } from "../../lib/fixtures/aristotle";
export function fixtureToDoc(data: FixtureData): JSONContent {
const content: JSONContent[] = data.narrative.map(node => narrativeNodeToJSON(node));
return {
type: "doc",
content,
};
}
function narrativeNodeToJSON(node: NarrativeNode): JSONContent {
if (node.type === "h1") {
return {
type: "heading",
attrs: { level: 1 },
content: node.text ? [{ type: "text", text: node.text }] : [],
};
}
if (node.type === "h2") {
return {
type: "heading",
attrs: { level: 2 },
content: node.text ? [{ type: "text", text: node.text }] : [],
};
}
// paragraph
const inline: JSONContent[] = [];
for (const child of node.children ?? []) {
if (child.t === "text") {
inline.push({ type: "text", text: child.v });
} else if (child.t === "chip") {
inline.push({
type: "chip",
attrs: { kind: child.kind, refId: child.id, label: child.label },
});
}
}
return { type: "paragraph", content: inline };
}

View File

@@ -0,0 +1,92 @@
// Suggestion plugin config that wires the slash menu into TipTap.
// Renders SlashMenu in a fixed-position floating panel near the caret.
"use client";
import type { Editor, Range } from "@tiptap/core";
import type { SuggestionOptions, SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
import { createRoot, type Root } from "react-dom/client";
import { createElement, createRef } from "react";
import { SlashMenu, SLASH_ITEMS, type SlashItem, type SlashMenuHandle } from "./SlashMenu";
export const slashSuggestion: Omit<SuggestionOptions<SlashItem, SlashItem>, "editor"> = {
char: "/",
startOfLine: false,
allowSpaces: false,
items: ({ query }) =>
SLASH_ITEMS.filter(
item =>
query.length === 0 ||
item.kind.toLowerCase().startsWith(query.toLowerCase()) ||
item.label.toLowerCase().startsWith(query.toLowerCase())
),
command: ({ editor, range, props }: { editor: Editor; range: Range; props: SlashItem }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertChip({
kind: props.kind,
refId: null,
label: props.kind === "requirement" ? "REQ-001" : "untitled",
})
.run();
},
render: () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
const handleRef = createRef<SlashMenuHandle>();
function position(rect: DOMRect | null) {
if (!container || !rect) return;
container.style.position = "fixed";
container.style.top = `${rect.bottom + 6}px`;
container.style.left = `${rect.left}px`;
container.style.zIndex = "1000";
}
function rerender(props: SuggestionProps<SlashItem>) {
if (!root) return;
root.render(
createElement(SlashMenu, {
ref: handleRef,
query: props.query,
command: (item: SlashItem) => props.command(item),
})
);
}
return {
onStart(props) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
rerender(props);
position(props.clientRect?.() ?? null);
},
onUpdate(props) {
rerender(props);
position(props.clientRect?.() ?? null);
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === "Escape") {
props.event.preventDefault();
return true;
}
return handleRef.current?.onKeyDown(props.event) ?? false;
},
onExit() {
if (root) root.unmount();
if (container && container.parentNode) container.parentNode.removeChild(container);
root = null;
container = null;
},
};
},
};