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

View File

@@ -0,0 +1,22 @@
// Header above each canvas (Narrative / Model). Title + subtitle on the left, optional actions on the right.
// Ported from docs/design-source/socrata/project/editor-shell.jsx (CanvasHeader).
import type { ReactNode } from "react";
interface CanvasHeaderProps {
title: string;
subtitle: string;
right?: ReactNode;
}
export function CanvasHeader({ title, subtitle, right }: CanvasHeaderProps) {
return (
<div className="canvas-header">
<div>
<div className="canvas-title">{title}</div>
<div className="canvas-sub">{subtitle}</div>
</div>
<div className="canvas-header-right">{right}</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
// The dual-canvas workspace shell.
// Composes TopBar / SocratesDock / LeftRail / TextCanvas / DiagramCanvas / StatusBar.
// Ported from docs/design-source/socrata/project/editor-shell.jsx (EditorShell).
"use client";
import { useState } from "react";
import { TopBar } from "./TopBar";
import { LeftRail } from "./LeftRail";
import { CanvasHeader } from "./CanvasHeader";
import { StatusBar } from "./StatusBar";
import { TextCanvas } from "../text-canvas/TextCanvas";
import { DiagramCanvas, type DiagramVariant } from "../diagram-canvas/DiagramCanvas";
import { SocratesDock, type Density, type SocratesPresence } from "../socrates/SocratesDock";
import type { MarkupStyle } from "../text-canvas/Chip";
import type { FixtureData } from "../../lib/fixtures/aristotle";
interface EditorShellProps {
data: FixtureData;
density?: Density;
markupStyle?: MarkupStyle;
diagramStyle?: DiagramVariant;
presence?: SocratesPresence;
}
export function EditorShell({
data,
density = "comfortable",
markupStyle = "color",
diagramStyle = "softened",
presence = "default",
}: EditorShellProps) {
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
return (
<div className={`shell shell-density-${density} shell-presence-${presence}`}>
<TopBar data={data} />
<div className="shell-body">
<SocratesDock thread={data.socratesThread} presence={presence} density={density} />
<LeftRail data={data} focusBlockId={focusBlockId} setFocusBlockId={setFocusBlockId} />
<main className="canvases">
<section className="canvas canvas-text">
<CanvasHeader title="Narrative" subtitle="Markup-augmented prose · synced to model" />
<div className="canvas-scroll">
<TextCanvas
data={data}
density={density}
markupStyle={markupStyle}
focusBlockId={focusBlockId}
setFocusBlockId={setFocusBlockId}
/>
</div>
</section>
<div className="canvas-divider" />
<section className="canvas canvas-diagram">
<CanvasHeader
title="Model"
subtitle="SysML · 6 blocks · 6 associations · 1 constraint"
right={
<div className="canvas-actions">
<span className="canvas-mode-pill">Fit</span>
<span className="canvas-mode-pill canvas-mode-active">100%</span>
<span className="canvas-mode-pill">Layout</span>
</div>
}
/>
<div className="canvas-scroll canvas-scroll-diagram">
<DiagramCanvas
data={data}
density={density}
variant={diagramStyle}
focusBlockId={focusBlockId}
onSelect={setFocusBlockId}
/>
</div>
</section>
</main>
</div>
<StatusBar data={data} />
</div>
);
}

View File

@@ -0,0 +1,140 @@
// 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).
"use client";
import { useState } from "react";
import type { FixtureData } from "../../lib/fixtures/aristotle";
interface LeftRailProps {
data: FixtureData;
focusBlockId: string | null;
setFocusBlockId: (id: string | null) => void;
}
export function LeftRail({ data, focusBlockId, setFocusBlockId }: 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] }));
if (collapsed) {
return (
<nav className="leftrail leftrail-collapsed">
<button
className="rail-collapse-btn"
onClick={() => setCollapsed(false)}
title="Expand rail"
type="button"
>
</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="Requirements">REQ</span>
</div>
</nav>
);
}
return (
<nav className="leftrail">
<div className="rail-section">
<button className="rail-section-head" onClick={() => toggle("outline")} type="button">
<span className={`rail-caret ${open.outline ? "rail-caret-open" : ""}`}></span>
<span className="rail-label">Outline</span>
</button>
{open.outline && (
<ul className="rail-list">
<li className="rail-item rail-item-active">
<span className="rail-item-text">Problem framing</span>
</li>
<li className="rail-item">
<span className="rail-item-text">Constraints</span>
<span className="rail-count rail-count-req">3</span>
</li>
<li className="rail-item">
<span className="rail-item-text">Why now</span>
</li>
<li className="rail-item rail-item-muted">
<span className="rail-item-text">Hypotheses</span>
<span className="rail-count rail-count-asm">3</span>
</li>
<li className="rail-item rail-item-muted">
<span className="rail-item-text">Open questions</span>
<span className="rail-count rail-count-q">5</span>
</li>
<li className="rail-item rail-item-muted">
<span className="rail-item-text">Risks</span>
<span className="rail-count rail-count-risk">2</span>
</li>
</ul>
)}
</div>
<div className="rail-section">
<button className="rail-section-head" onClick={() => toggle("model")} type="button">
<span className={`rail-caret ${open.model ? "rail-caret-open" : ""}`}></span>
<span className="rail-label">Model</span>
</button>
{open.model && (
<ul className="rail-list rail-blocks">
{data.blocks.map(b => (
<li
key={b.id}
className={`rail-block rail-${b.kind} ${focusBlockId === b.id ? "rail-block-active" : ""}`}
onMouseEnter={() => setFocusBlockId(b.id)}
onMouseLeave={() => setFocusBlockId(null)}
>
<span className="rail-block-glyph">
{b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : "▢"}
</span>
<span className="rail-block-label">{b.label}</span>
<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>
</li>
))}
</ul>
)}
</div>
<div className="rail-section">
<button className="rail-section-head" onClick={() => toggle("requirements")} type="button">
<span className={`rail-caret ${open.requirements ? "rail-caret-open" : ""}`}></span>
<span className="rail-label">Requirements</span>
</button>
{open.requirements && (
<ul className="rail-list">
<li className="rail-req">
<span className="req-tag">REQ-001</span>
<span className="req-status req-traced" />
</li>
<li className="rail-req">
<span className="req-tag">REQ-002</span>
<span className="req-status req-traced" />
</li>
<li className="rail-req">
<span className="req-tag">REQ-003</span>
<span className="req-status req-untraced" />
</li>
</ul>
)}
</div>
<button
className="rail-collapse-btn rail-collapse-btn-bottom"
onClick={() => setCollapsed(true)}
title="Collapse rail"
type="button"
>
</button>
</nav>
);
}

View File

@@ -0,0 +1,20 @@
// Bottom status bar with version + summary + owner.
// Ported from docs/design-source/socrata/project/editor-shell.jsx (statusbar block).
import type { FixtureData } from "../../lib/fixtures/aristotle";
interface StatusBarProps {
data: FixtureData;
}
export function StatusBar({ data }: StatusBarProps) {
return (
<footer className="statusbar">
<span>Socrata · v0.4 · Phase 1</span>
<span className="status-spacer" />
<span>3 assumptions open · 2 risks tracked · 1 proposal pending</span>
<span className="status-spacer" />
<span>{data.project.owner}</span>
</footer>
);
}

View File

@@ -0,0 +1,36 @@
// Top-level brand row: name + breadcrumbs on the left, sync pill + avatar on the right.
// Ported from docs/design-source/socrata/project/editor-shell.jsx (TopBar).
import type { FixtureData } from "../../lib/fixtures/aristotle";
interface TopBarProps {
data: FixtureData;
}
export function TopBar({ data }: TopBarProps) {
return (
<header className="topbar">
<div className="topbar-left">
<div className="brand">
<span className="brand-name">Socrata</span>
</div>
<div className="breadcrumbs">
<span className="bc-sep">/</span>
<span className="bc-item">{data.project.scope}</span>
<span className="bc-sep">/</span>
<span className="bc-item bc-active">{data.project.name}</span>
<span className="bc-branch">
<span className="bc-branch-glyph"></span> {data.project.branch}
</span>
</div>
</div>
<div className="topbar-right">
<div className="sync-pill">
<span className="sync-dot" /> model in sync · {data.project.lastSync}
</div>
<div className="topbar-divider" />
<div className="avatar">MC</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,185 @@
// Seed onboarding screen — two-column layout: emerging-seed rail (left) + Socrates conversation (right).
// Ported from docs/design-source/socrata/project/seed-screen.jsx.
import { Sigil } from "../socrates/Sigil";
interface SeedField {
key: string;
label: string;
value: string;
inferred?: boolean;
}
interface ThreadTurn {
who: "socrates" | "user";
text: string;
}
const fields: SeedField[] = [
{
key: "problem",
label: "Problem",
value: "First-year STEM students disengage in the long tail between lectures and office hours; a self-efficacy gap forms quickly.",
},
{
key: "user",
label: "Target user",
value: "Undergraduates at large public universities, weeks 310 of an intro course.",
},
{
key: "outcome",
label: "Desired outcome",
value: "Students re-engage with material via a low-stakes thinking partner — without producing solutions.",
},
{
key: "hypothesis",
label: "Initial hypothesis",
value: "Students will adopt a tool that explicitly refuses to solve their homework.",
inferred: true,
},
{
key: "constraint",
label: "Constraint",
value: "FERPA tenancy, P50 < 1.2s.",
inferred: true,
},
];
const thread: ThreadTurn[] = [
{
who: "socrates",
text: "Welcome. I'm Socrates. Before we model anything, let me understand what you're really proposing. In one sentence — what is the smallest, most honest version of the problem?",
},
{
who: "user",
text: "Students disengage between lectures because they have nobody to think with at 11pm.",
},
{
who: "socrates",
text: "Good. Two follow-ups. First, who specifically — and why now? Second, when you say 'think with', do you mean a tutor that explains, or a partner that asks? These are quite different products.",
},
{
who: "user",
text: "Public-university undergrads, weeks 310. A partner that asks. The market is saturated with explainers.",
},
{
who: "socrates",
text: "Then the central tension is restraint: a tool that holds its tongue. Most LLM products are rewarded for being helpful. Yours will be rewarded for being patient. Should I draft this as a Constraint on the model — refusal_policy : single-valued — and surface it for your review?",
},
];
export function SeedScreen() {
return (
<div className="seed-screen">
<header className="seed-top">
<div className="seed-top-left">
<Sigil size={28} />
<span className="seed-brand">Socrata</span>
<span className="seed-pip">·</span>
<span className="seed-step">Seed · forming</span>
</div>
<div className="seed-top-right">
<span className="seed-mode-pill seed-mode-active">Interview</span>
<span className="seed-mode-pill">Form</span>
</div>
</header>
<div className="seed-body">
{/* Left: emerging seed */}
<section className="seed-left">
<div className="seed-section-label">Emerging seed</div>
<div className="seed-fields">
{fields.map(f => (
<div key={f.key} className={`seed-field ${f.inferred ? "seed-field-inferred" : ""}`}>
<div className="seed-field-label">
{f.label}
{f.inferred && <span className="seed-conf">inferred · 0.74</span>}
</div>
<div className="seed-field-value">{f.value}</div>
</div>
))}
</div>
<div className="seed-section-label seed-section-label-2">Initial model · drafting</div>
<div className="seed-mini-graph">
<div className="mini-block mini-block-1">
<span className="mini-stereo">«block»</span>
<span className="mini-name">Student</span>
<span className="mini-prop">self_efficacy</span>
</div>
<div className="mini-edge" />
<div className="mini-block mini-block-2 mini-block-focus">
<span className="mini-stereo">«block»</span>
<span className="mini-name">Aristotle</span>
<span className="mini-prop">refusal_policy</span>
<span className="mini-prop">interaction_style</span>
</div>
<div className="mini-edge mini-edge-down" />
<div className="mini-block mini-block-3">
<span className="mini-stereo">«constraint»</span>
<span className="mini-name">FERPA boundary</span>
</div>
</div>
<div className="seed-confidence">
<div className="seed-confidence-row">
<span>Model confidence</span>
<span>0.62</span>
</div>
<div className="seed-confidence-bar">
<div className="seed-confidence-fill" style={{ width: "62%" }} />
</div>
<div className="seed-confidence-hint">
Three more clarifying questions should bring this above 0.80.
</div>
</div>
</section>
{/* Right: Socrates conversation */}
<section className="seed-right">
<div className="seed-thread">
{thread.map((m, i) => (
<div key={i} className={`seed-bubble seed-bubble-${m.who}`}>
{m.who === "socrates" && (
<div className="seed-bubble-avatar">
<Sigil size={32} />
</div>
)}
<div className="seed-bubble-body">
<div className="seed-bubble-who">{m.who === "socrates" ? "Socrates" : "You"}</div>
<div className="seed-bubble-text">{m.text}</div>
</div>
</div>
))}
<div className="seed-bubble seed-bubble-socrates seed-bubble-typing">
<div className="seed-bubble-avatar">
<Sigil size={32} />
</div>
<div className="seed-bubble-body">
<div className="seed-bubble-who">Socrates</div>
<div className="seed-typing">
<span /><span /><span /> drafting next question
</div>
</div>
</div>
</div>
<div className="seed-input-row">
<div className="seed-input">
<span className="seed-input-prompt"></span>
<span className="seed-input-text">
Public-university undergrads, weeks 310. A partner that asks. The market is saturated with explainers.
</span>
<span className="seed-input-caret" />
</div>
<div className="seed-input-actions">
<button className="seed-btn" type="button">Save draft</button>
<button className="seed-btn seed-btn-primary" type="button">Send · </button>
</div>
</div>
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
// Σ inside a softened laurel, themed via CSS variables.
// Ported from docs/design-source/socrata/project/socrates.jsx (SocratesSigil).
interface SigilProps {
size?: number;
mood?: "thinking" | "still";
}
export function Sigil({ size = 44, mood = "thinking" }: SigilProps) {
return (
<div className="sigil" style={{ width: size, height: size }}>
<svg viewBox="0 0 64 64" width={size} height={size}>
<defs>
<radialGradient id="sigilGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="var(--accent-soft)" stopOpacity="0.55" />
<stop offset="100%" stopColor="var(--accent-soft)" stopOpacity="0" />
</radialGradient>
</defs>
<circle cx="32" cy="32" r="30" fill="url(#sigilGlow)" />
<circle cx="32" cy="32" r="22" fill="var(--surface-2)" stroke="var(--accent)" strokeWidth="1.25" />
{/* Laurel */}
<path d="M 12 32 Q 18 18 32 14" fill="none" stroke="var(--accent)" strokeWidth="1" opacity="0.45" />
<path d="M 52 32 Q 46 46 32 50" fill="none" stroke="var(--accent)" strokeWidth="1" opacity="0.45" />
{/* Σ */}
<text
x="32"
y="40"
textAnchor="middle"
fontSize="22"
fontFamily="var(--font-display)"
fontWeight="600"
fill="var(--accent)"
>
Σ
</text>
{mood === "thinking" && (
<circle cx="50" cy="50" r="3" fill="var(--accent)" className="sigil-pulse" />
)}
</svg>
</div>
);
}

View File

@@ -0,0 +1,82 @@
// Active-thread dock with Sigil header, conversation bubbles, numbered options.
// Ported from docs/design-source/socrata/project/socrates.jsx (SocratesDock).
// "subtle" presence renders a floating sigil + count badge.
import { Sigil } from "./Sigil";
import type { FixtureSocratesTurn } from "../../lib/fixtures/aristotle";
export type SocratesPresence = "subtle" | "default" | "prominent";
export type Density = "comfortable" | "compact";
interface SocratesDockProps {
thread: FixtureSocratesTurn[];
presence: SocratesPresence;
density: Density;
}
export function SocratesDock({ thread, presence }: SocratesDockProps) {
if (presence === "subtle") {
return (
<div className="dock dock-subtle">
<Sigil size={36} />
<div className="dock-subtle-count">3</div>
</div>
);
}
return (
<aside className={`dock ${presence === "prominent" ? "dock-prominent" : "dock-default"}`}>
<header className="dock-header">
<Sigil size={28} />
<div className="dock-header-text">
<div className="dock-name">Socrates</div>
<div className="dock-status">
<span className="dock-dot" /> 2 open threads
</div>
</div>
<button className="dock-header-action" title="New thread" type="button">
+
</button>
</header>
<section className="dock-thread-wrap">
<div className="dock-section-label dock-section-label-inline">Active thread · Aristotle</div>
<div className="dock-thread">
{thread.map((m, i) => (
<div key={i} className={`bubble bubble-${m.who}`}>
{m.who === "socrates" && <span className="bubble-sigil">Σ</span>}
<span className="bubble-body">
<span className="bubble-text">{m.text}</span>
{m.options && (
<div className="bubble-options">
{m.options.map(o => (
<button key={o.n} className="bubble-option" type="button">
<span className="bubble-option-num">{o.n}</span>
<span className="bubble-option-text">
<span className="bubble-option-label">{o.label}</span>
<span className="bubble-option-sub">{o.sub}</span>
</span>
<span className="bubble-option-key">{o.n}</span>
</button>
))}
<div className="bubble-options-hint">
Press <kbd>1</kbd><kbd>{m.options.length}</kbd>, or type a reply
</div>
</div>
)}
</span>
</div>
))}
</div>
</section>
<footer className="dock-input-wrap">
<div className="dock-input">
<span className="dock-input-prompt"></span>
<span className="dock-input-placeholder">Reply to Socrates</span>
<span className="dock-input-shortcut"></span>
</div>
</footer>
</aside>
);
}

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