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