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

41
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
apps/web/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,8 @@
import { EditorShell } from "../../../components/editor/EditorShell";
import { aristotleFixture } from "../../../lib/fixtures/aristotle";
// M1: every projectId resolves to the Aristotle fixture.
// M4M5 wire this to a real database lookup.
export default async function EditorPage(_props: { params: Promise<{ projectId: string }> }) {
return <EditorShell data={aristotleFixture} />;
}

30
apps/web/app/layout.tsx Normal file
View File

@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import "../styles/base.css";
import "../styles/theme-manuscript.css";
import "@xyflow/react/dist/style.css";
import "../styles/diagram.css";
export const metadata: Metadata = {
title: "Socrata",
description: "Structured thinking and validation platform for product managers.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Newsreader:ital,wght@0,400;0,500;0,600;1,400;1,500;1,600&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body className="theme-manuscript">{children}</body>
</html>
);
}

50
apps/web/app/page.tsx Normal file
View File

@@ -0,0 +1,50 @@
import Link from "next/link";
export default function HomePage() {
return (
<div style={{ padding: "60px 80px", maxWidth: 720, margin: "0 auto", lineHeight: 1.55 }}>
<h1 style={{ fontFamily: "var(--font-display)", fontSize: 32, fontWeight: 600, marginBottom: 8 }}>
Socrata
</h1>
<p style={{ color: "var(--muted)", marginBottom: 32 }}>
Structured thinking and validation platform for product managers.
</p>
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: 12 }}>
<li>
<Link
href="/editor/aristotle"
style={{
display: "inline-block",
padding: "12px 18px",
border: "1px solid var(--border-strong)",
borderRadius: 6,
color: "var(--accent)",
fontFamily: "var(--font-mono)",
fontSize: 13,
textDecoration: "none",
}}
>
Open editor (Aristotle fixture)
</Link>
</li>
<li>
<Link
href="/seed"
style={{
display: "inline-block",
padding: "12px 18px",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--muted)",
fontFamily: "var(--font-mono)",
fontSize: 13,
textDecoration: "none",
}}
>
Seed screen (coming next)
</Link>
</li>
</ul>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { SeedScreen } from "../../components/seed/SeedScreen";
export default function SeedPage() {
return <SeedScreen />;
}

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

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,202 @@
// Sample project content — a realistic PM idea: an AI study companion for university students.
// Ported from docs/design-source/socrata/project/data.js.
// Used to populate the textual narrative + diagram during M1's static visual port.
export type ChipKind = "block" | "property" | "association" | "requirement";
export interface ChipToken {
t: "chip";
kind: ChipKind;
id: string;
label: string;
}
export interface TextToken {
t: "text";
v: string;
}
export type NarrativeInline = ChipToken | TextToken;
export interface NarrativeNode {
type: "h1" | "h2" | "p";
text?: string;
children?: NarrativeInline[];
}
export type BlockKind = "block" | "actor" | "constraint";
export interface FixtureBlock {
id: string;
label: string;
kind: BlockKind;
/** Position normalized to a 720×460 board (matches diagram.jsx). */
x: number;
y: number;
w: number;
h: number;
properties: string[];
}
export type AssociationKind = "association" | "composition" | "constraint";
export interface FixtureAssociation {
id: string;
from: string;
to: string;
label: string;
kind: AssociationKind;
}
export interface FixtureAssumption {
id: string;
text: string;
status: "open" | "validated" | "invalidated";
linked: string[];
}
export interface FixtureRisk {
id: string;
text: string;
severity: "low" | "med" | "high";
linked: string[];
}
export interface FixtureSocratesTurn {
who: "socrates" | "user";
text: string;
options?: Array<{ n: number; label: string; sub: string }>;
}
export interface FixtureProposal {
title: string;
impactedBlocks: string[];
addedConstraints: number;
affectedRequirements: string[];
iteration: number;
}
export interface FixtureData {
project: {
name: string;
tagline: string;
scope: string;
owner: string;
branch: string;
lastSync: string;
};
narrative: NarrativeNode[];
blocks: FixtureBlock[];
associations: FixtureAssociation[];
assumptions: FixtureAssumption[];
risks: FixtureRisk[];
socratesThread: FixtureSocratesTurn[];
proposal: FixtureProposal;
}
export const aristotleFixture: FixtureData = {
project: {
name: "Aristotle",
tagline: "AI study companion for first-year STEM students",
scope: "Higher education · undergraduate",
owner: "M. Chen · PM",
branch: "main",
lastSync: "2 min ago",
},
narrative: [
{ type: "h1", text: "Problem framing" },
{
type: "p",
children: [
{ t: "text", v: "First-year STEM students at large public universities frequently disengage from coursework not because the material is intractable, but because the " },
{ t: "chip", kind: "block", id: "student", label: "Student" },
{ t: "text", v: " lacks a low-stakes thinking partner during the long tail between lectures and office hours. The " },
{ t: "chip", kind: "block", id: "course", label: "Course" },
{ t: "text", v: " produces problem sets that assume mastery of prerequisite scaffolding, and a " },
{ t: "chip", kind: "property", id: "selfEfficacy", label: "self_efficacy" },
{ t: "text", v: " gap forms quickly." },
],
},
{
type: "p",
children: [
{ t: "text", v: "Aristotle is a study companion that " },
{ t: "chip", kind: "association", id: "guides", label: "guides" },
{ t: "text", v: " the student through Socratic prompts rather than answers, scoped to their current " },
{ t: "chip", kind: "block", id: "assignment", label: "Assignment" },
{ t: "text", v: ". It refuses to produce solutions; it only produces questions calibrated to a student's evolving understanding." },
],
},
{ type: "h2", text: "Constraints" },
{
type: "p",
children: [
{ t: "chip", kind: "requirement", id: "REQ-001", label: "REQ-001" },
{ t: "text", v: " The companion must never output a complete solution to a graded problem. " },
{ t: "chip", kind: "requirement", id: "REQ-002", label: "REQ-002" },
{ t: "text", v: " Response latency under 1.2s P50 to preserve flow. " },
{ t: "chip", kind: "requirement", id: "REQ-003", label: "REQ-003" },
{ t: "text", v: " Operates within FERPA boundaries; coursework never leaves institutional tenancy." },
],
},
{ type: "h2", text: "Why now" },
{
type: "p",
children: [
{ t: "text", v: "Two large public-university pilots indicated that students would adopt a tool that explicitly does not solve their homework — an inversion of the prevailing market." },
],
},
],
blocks: [
{ id: "student", label: "Student", kind: "block", x: 0.10, y: 0.18, w: 168, h: 96,
properties: ["self_efficacy", "course_load", "prior_grade"] },
{ id: "aristotle", label: "Aristotle", kind: "block", x: 0.42, y: 0.18, w: 184, h: 110,
properties: ["interaction_style", "scope_window", "refusal_policy"] },
{ id: "course", label: "Course", kind: "block", x: 0.74, y: 0.10, w: 168, h: 96,
properties: ["syllabus", "prerequisites"] },
{ id: "assignment", label: "Assignment", kind: "block", x: 0.74, y: 0.58, w: 168, h: 96,
properties: ["due_at", "rubric", "graded"] },
{ id: "instructor", label: "Instructor", kind: "actor", x: 0.10, y: 0.62, w: 144, h: 76,
properties: ["policy_set"] },
{ id: "ferpa", label: "FERPA boundary", kind: "constraint", x: 0.42, y: 0.66, w: 184, h: 72,
properties: [] },
],
associations: [
{ id: "a1", from: "student", to: "aristotle", label: "consults", kind: "association" },
{ id: "a2", from: "aristotle", to: "assignment", label: "scoped_to", kind: "association" },
{ id: "a3", from: "course", to: "assignment", label: "contains", kind: "composition" },
{ id: "a4", from: "instructor", to: "aristotle", label: "configures", kind: "association" },
{ id: "a5", from: "aristotle", to: "ferpa", label: "obeys", kind: "constraint" },
{ id: "a6", from: "student", to: "course", label: "enrolled_in", kind: "association" },
],
assumptions: [
{ id: "ASM-1", text: "Students will accept a tool that refuses to give answers", status: "open", linked: ["aristotle"] },
{ id: "ASM-2", text: "Faculty will not classify Socratic prompts as academic dishonesty", status: "validated", linked: ["aristotle", "instructor"] },
{ id: "ASM-3", text: "1.2s P50 latency is achievable with on-prem inference", status: "open", linked: ["aristotle"] },
],
risks: [
{ id: "RSK-1", text: "Refusal policy circumvented via prompt injection", severity: "high", linked: ["aristotle"] },
{ id: "RSK-2", text: "FERPA tenancy adds 46 weeks of procurement per institution", severity: "med", linked: ["ferpa"] },
],
socratesThread: [
{ who: "socrates", text: "You've defined Aristotle as scoped to a single Assignment. What happens when a student wants to compare techniques across two assignments — does the scope expand, or does the student switch contexts?" },
{ who: "user", text: "Switch contexts. We don't want cross-assignment leakage of solution patterns." },
{ who: "socrates", text: "Good. That decision has an implication: the property scope_window on Aristotle becomes single-valued, and we may want a Constraint that forbids cross-Assignment retrieval. Should I draft that as a proposal?" },
{
who: "socrates",
text: "Given the context-switch decision, three things follow. Pick where to go next:",
options: [
{ n: 1, label: "Draft the constraint", sub: "no cross-Assignment retrieval" },
{ n: 2, label: "Revisit scope_window", sub: "single-valued vs. multi-valued" },
{ n: 3, label: "Park it", sub: "I'll come back later" },
],
},
],
proposal: {
title: "Add constraint: no cross-Assignment retrieval",
impactedBlocks: ["aristotle", "assignment"],
addedConstraints: 1,
affectedRequirements: ["REQ-001"],
iteration: 2,
},
};

7
apps/web/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

31
apps/web/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@tiptap/core": "^3.22.5",
"@tiptap/extension-mention": "^3.22.5",
"@tiptap/pm": "^3.22.5",
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"@tiptap/suggestion": "^3.22.5",
"@xyflow/react": "^12.10.2",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"typescript": "^5"
}
}

4403
apps/web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

925
apps/web/styles/base.css Normal file
View File

@@ -0,0 +1,925 @@
/* ─── Base shared styles (theme variables defined per-aesthetic in HTML) ─── */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: var(--font-body);
color: var(--fg);
background: var(--bg);
/* Direct children fill the viewport */
display: flex;
flex-direction: column;
min-height: 100dvh;
}
body > * { flex: 1; min-height: 0; }
button { font-family: inherit; }
/* ─── Shell ─── */
.shell {
width: 100%;
height: 100dvh;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: 13.5px;
line-height: 1.55;
overflow: hidden;
letter-spacing: var(--tracking);
}
.shell-density-compact { font-size: 12.5px; line-height: 1.45; }
/* ─── Top bar ─── */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.topbar-left { display: flex; align-items: center; gap: 14px; }
.brand { display: flex; align-items: center; gap: 8px; }
.brand-name { font-family: var(--font-display); font-weight: 600; font-size: 15px; letter-spacing: 0.01em; }
.breadcrumbs { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); font-family: var(--font-mono); }
.bc-sep { opacity: 0.45; }
.bc-active { color: var(--fg); }
.bc-branch { margin-left: 10px; padding: 2px 8px; border-radius: 4px; background: var(--accent-soft); color: var(--accent-strong); font-size: 11.5px; }
.bc-branch-glyph { margin-right: 4px; }
.topbar-right { display: flex; align-items: center; gap: 10px; }
.sync-pill { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--muted); font-family: var(--font-mono); }
.sync-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
.topbar-divider { width: 1px; height: 18px; background: var(--border); }
.icon-btn {
width: 26px; height: 26px; border-radius: 5px; border: 1px solid transparent;
background: transparent; color: var(--muted); font-size: 13px;
display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
font-family: var(--font-mono);
}
.icon-btn:hover { background: var(--surface-2); color: var(--fg); border-color: var(--border); }
.avatar {
width: 26px; height: 26px; border-radius: 50%;
background: var(--accent); color: var(--accent-on);
font-size: 11px; font-weight: 600;
display: inline-flex; align-items: center; justify-content: center;
font-family: var(--font-display); letter-spacing: 0.02em;
}
/* ─── Body layout ─── */
.shell-body {
flex: 1;
display: grid;
grid-template-columns: 320px 184px 1fr;
min-height: 0;
}
.shell-density-compact .shell-body { grid-template-columns: 296px 168px 1fr; }
.shell-presence-prominent .shell-body { grid-template-columns: 360px 184px 1fr; }
.shell-presence-subtle .shell-body { grid-template-columns: 0px 184px 1fr; }
/* ─── Left rail ─── */
.leftrail {
border-right: 1px solid var(--border);
border-left: 1px solid var(--border);
background: var(--surface);
padding: 8px 8px;
overflow-y: auto;
font-size: 11.5px;
position: relative;
}
.leftrail-collapsed {
padding: 14px 0;
display: flex; flex-direction: column; align-items: center; gap: 14px;
width: 36px;
min-width: 36px;
}
.rail-collapsed-stack {
display: flex; flex-direction: column; gap: 18px;
margin-top: 8px;
}
.rail-collapsed-tag {
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.08em;
color: var(--muted); writing-mode: vertical-rl; text-orientation: mixed;
cursor: pointer;
}
.rail-collapsed-tag:hover { color: var(--fg); }
.rail-section-head {
display: flex; align-items: center; gap: 5px;
width: 100%;
background: transparent; border: none; padding: 0 4px;
cursor: pointer;
margin-bottom: 4px;
color: inherit;
height: 20px;
}
.rail-section-head:hover .rail-label { color: var(--fg); }
.rail-caret { display: none; }
.rail-section .rail-label { margin-bottom: 0; padding: 0; }
.rail-collapse-btn {
position: absolute; top: 8px; right: 6px;
width: 18px; height: 18px; border-radius: 4px;
border: none; background: transparent; color: var(--muted);
cursor: pointer; font-size: 11px; line-height: 1;
display: flex; align-items: center; justify-content: center;
}
.rail-collapse-btn:hover { background: var(--surface-2); color: var(--fg); }
.leftrail-collapsed .rail-collapse-btn { position: static; }
.rail-collapse-btn-bottom { display: none; }
.shell-density-compact .leftrail { padding: 10px 10px; font-size: 11.5px; }
.rail-section { margin-bottom: 10px; }
.rail-label {
text-transform: uppercase;
font-size: 9.5px;
font-family: var(--font-mono);
letter-spacing: 0.10em;
color: var(--muted);
margin-bottom: 4px;
padding: 0 4px;
}
.rail-list { list-style: none; padding: 0; margin: 0; }
.rail-item {
padding: 2px 6px; border-radius: 3px; cursor: pointer; color: var(--fg);
display: flex; align-items: center; gap: 6px;
font-size: 11.5px; line-height: 1.35;
}
.rail-item-text { flex: 1; }
.rail-count {
font-family: var(--font-mono); font-size: 9.5px;
padding: 1px 5px; border-radius: 3px;
background: var(--surface-2); color: var(--muted-strong);
letter-spacing: 0.02em;
flex-shrink: 0;
}
.rail-count-asm { background: var(--c-prop-bg); color: var(--c-prop-fg); }
.rail-count-req { background: var(--c-block-bg); color: var(--c-block-fg); }
.rail-count-q { background: var(--accent-soft); color: var(--accent-strong); }
.rail-count-risk { background: var(--warn-soft); color: var(--warn-strong); }
.rail-item:hover { background: var(--surface-2); }
.rail-item-active { background: var(--accent-soft); color: var(--accent-strong); }
.rail-item-muted { color: var(--muted); }
.rail-blocks { display: flex; flex-direction: column; gap: 1px; }
.rail-block {
display: flex; align-items: center; gap: 6px;
padding: 2px 6px; border-radius: 3px; cursor: pointer;
font-family: var(--font-mono); font-size: 10.5px;
line-height: 1.4;
}
.rail-block:hover { background: var(--surface-2); }
.rail-block-active { background: var(--accent-soft); color: var(--accent-strong); }
.rail-block-glyph { color: var(--muted); width: 12px; }
.rail-constraint .rail-block-glyph { color: var(--warn); }
.rail-actor .rail-block-glyph { color: var(--info); }
.rail-block-label { flex: 1; }
.rail-block-count {
font-size: 10px; color: var(--muted);
background: var(--surface-2); padding: 1px 5px; border-radius: 3px;
display: inline-flex; align-items: center; gap: 3px;
cursor: help;
}
.rail-block-count-glyph { opacity: 0.6; }
.rail-req {
display: flex; align-items: center; justify-content: space-between;
padding: 2px 6px; font-family: var(--font-mono); font-size: 10px; color: var(--muted-strong);
}
.req-tag { letter-spacing: 0.04em; }
.req-status { width: 6px; height: 6px; border-radius: 50%; }
.req-traced { background: var(--ok); }
.req-untraced { background: var(--warn); }
/* ─── Canvases ─── */
.canvases { display: grid; grid-template-columns: 1fr 1px 1fr; min-height: 0; background: var(--bg); }
.canvas { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
.canvas-divider { background: var(--border); }
.canvas-header {
height: auto; min-height: 44px; padding: 8px 16px; flex-shrink: 0;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid var(--border); background: var(--surface);
}
.shell-density-compact .canvas-header { min-height: 38px; padding: 6px 12px; }
.canvas-title { font-family: var(--font-display); font-weight: 600; font-size: 12.5px; letter-spacing: 0.02em; line-height: 1.3; }
.canvas-sub { font-size: 10.5px; color: var(--muted); font-family: var(--font-mono); line-height: 1.3; margin-top: 2px; }
.canvas-actions { display: flex; gap: 2px; background: var(--surface-2); padding: 2px; border-radius: 5px; }
.canvas-mode-pill {
font-size: 10.5px; padding: 3px 8px; border-radius: 4px;
color: var(--muted); cursor: pointer; font-family: var(--font-mono);
}
.canvas-mode-active { background: var(--surface); color: var(--fg); box-shadow: 0 1px 2px var(--shadow); }
.canvas-scroll { flex: 1; overflow-y: auto; min-height: 0; position: relative; }
.canvas-scroll-diagram { overflow: hidden; }
/* ─── Text canvas ─── */
.text-canvas {
max-width: 720px;
margin: 0 auto;
font-family: var(--font-prose);
color: var(--fg);
}
.t-h1 {
font-family: var(--font-display);
font-weight: 600;
font-size: 22px;
letter-spacing: -0.01em;
margin: 14px 0 16px 0;
color: var(--fg);
}
.t-h2 {
font-family: var(--font-display);
font-weight: 600;
font-size: 15px;
letter-spacing: 0.01em;
margin: 24px 0 8px 0;
color: var(--fg);
}
.t-p {
margin: 0 0 14px 0;
font-size: 14.5px;
line-height: 1.65;
color: var(--prose);
text-wrap: pretty;
}
.shell-density-compact .t-p { font-size: 13px; line-height: 1.55; margin-bottom: 10px; }
.shell-density-compact .t-h1 { font-size: 19px; }
.shell-density-compact .t-h2 { font-size: 13.5px; }
/* ─── Chips ─── */
.chip {
display: inline-flex; align-items: baseline; gap: 3px;
padding: 1px 6px; border-radius: 4px; cursor: pointer;
font-family: var(--font-mono);
font-size: 0.86em;
vertical-align: baseline;
transition: background .12s, box-shadow .12s;
white-space: nowrap;
}
.chip-glyph { font-size: 0.85em; opacity: 0.75; }
.chip-label { font-weight: 500; }
.chip-focus { box-shadow: 0 0 0 1.5px var(--accent); }
/* pill (default) */
.chip-style-pill { background: var(--chip-bg); color: var(--chip-fg); }
.chip-style-pill:hover { background: var(--chip-bg-hover); }
/* color-coded by kind */
.chip-style-color.chip-block { background: var(--c-block-bg); color: var(--c-block-fg); }
.chip-style-color.chip-property { background: var(--c-prop-bg); color: var(--c-prop-fg); }
.chip-style-color.chip-association { background: var(--c-assoc-bg); color: var(--c-assoc-fg); }
.chip-style-color.chip-requirement { background: var(--c-req-bg); color: var(--c-req-fg); }
/* underline */
.chip-style-underline {
background: transparent;
padding: 0 1px;
border-bottom: 1.5px solid var(--accent);
border-radius: 0;
color: var(--fg);
}
.chip-style-underline.chip-block { border-bottom-color: var(--c-block-fg); }
.chip-style-underline.chip-property { border-bottom-color: var(--c-prop-fg); }
.chip-style-underline.chip-association { border-bottom-color: var(--c-assoc-fg); }
.chip-style-underline.chip-requirement { border-bottom-color: var(--c-req-fg); }
.chip-style-underline:hover { background: var(--chip-bg); }
/* bracket */
.chip-style-bracket {
background: transparent;
color: var(--muted-strong);
padding: 0 1px;
}
.chip-style-bracket .chip-bracket { color: var(--muted); opacity: 0.6; }
.chip-style-bracket .chip-kind { color: var(--accent); margin-right: 3px; }
.chip-style-bracket .chip-label { color: var(--fg); }
.chip-style-bracket:hover { background: var(--chip-bg); border-radius: 3px; }
/* ─── Margin note ─── */
.margin-note {
display: flex; gap: 12px;
margin: 24px 0 8px 0;
padding: 12px 14px;
background: var(--note-bg);
border-left: 2px solid var(--accent);
border-radius: 0 6px 6px 0;
font-family: var(--font-prose);
font-size: 13px;
color: var(--prose);
}
.margin-note-glyph {
font-family: var(--font-display); font-weight: 600;
color: var(--accent); font-size: 17px; line-height: 1;
flex-shrink: 0;
}
.margin-note-who {
display: block;
font-family: var(--font-mono); font-size: 10.5px;
color: var(--accent); letter-spacing: 0.04em; text-transform: uppercase;
margin-bottom: 4px;
}
.margin-note-text { line-height: 1.5; }
/* ─── Diagram ─── */
.diagram-grid {
position: absolute; inset: 0;
background-image: radial-gradient(var(--grid-dot) 1px, transparent 1px);
background-size: 18px 18px;
background-position: 0 0;
opacity: 0.7;
pointer-events: none;
}
.diagram-legend {
position: absolute; bottom: 12px; left: 12px;
display: flex; gap: 14px;
background: var(--surface);
border: 1px solid var(--border);
padding: 6px 10px;
border-radius: 5px;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--muted);
}
.legend-sw { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; }
.legend-block { background: var(--block-bg); border: 1px solid var(--block-border); }
.legend-actor { background: var(--block-actor-bg); border: 1px solid var(--block-border); }
.legend-constraint { background: var(--block-constraint-bg); border: 1px dashed var(--block-border); }
/* ─── Socrates dock ─── */
.dock {
border-right: 1px solid var(--border);
background: var(--surface);
display: flex; flex-direction: column;
overflow: hidden;
font-size: 12.5px;
min-height: 0;
}
.dock-header {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.dock-header-text { flex: 1; min-width: 0; }
.dock-header-action {
width: 22px; height: 22px; border-radius: 4px;
border: 1px solid var(--border-strong);
background: transparent; color: var(--muted);
cursor: pointer; font-size: 13px; line-height: 1;
display: flex; align-items: center; justify-content: center;
}
.dock-header-action:hover { background: var(--surface-2); color: var(--fg); }
.dock-thread-wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px 10px 6px;
display: flex; flex-direction: column;
}
.dock-section-label-inline {
margin-bottom: 8px;
flex-shrink: 0;
}
.dock-thread {
display: flex; flex-direction: column; gap: 8px;
flex: 1;
}
.bubble {
padding: 8px 10px; border-radius: 8px;
font-family: var(--font-prose); font-size: 12.5px; line-height: 1.5;
display: flex; gap: 8px;
align-items: flex-start;
}
.bubble-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.bubble-text { display: block; }
.dock-input-wrap {
flex-shrink: 0;
padding: 8px 10px;
border-top: 1px solid var(--border);
background: var(--surface);
}
.dock-input { margin-top: 0; }
/* Numbered options inside a bubble */
.bubble-options {
display: flex; flex-direction: column; gap: 3px;
margin-top: 2px;
}
.bubble-option {
display: flex; align-items: center; gap: 8px;
padding: 4px 7px;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: 5px;
cursor: pointer;
text-align: left;
font-family: var(--font-prose);
color: var(--fg);
width: 100%;
transition: background .12s, border-color .12s;
}
.bubble-option:hover {
background: var(--surface-2);
border-color: var(--accent);
}
.bubble-option-num {
width: 15px; height: 15px; flex-shrink: 0;
border-radius: 3px;
background: var(--accent-soft); color: var(--accent-strong);
font-family: var(--font-mono); font-size: 9.5px; font-weight: 600;
display: flex; align-items: center; justify-content: center;
}
.bubble-option-text { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 6px; }
.bubble-option-label {
font-size: 11.5px; font-weight: 500; color: var(--fg);
line-height: 1.25;
}
.bubble-option-sub {
font-family: var(--font-mono); font-size: 9.5px;
color: var(--muted);
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bubble-option-key { display: none; }
.bubble-options-hint {
font-family: var(--font-mono); font-size: 9.5px;
color: var(--muted);
margin-top: 3px;
padding: 0 2px;
}
.bubble-options-hint kbd {
font-family: var(--font-mono); font-size: 9px;
padding: 0 3px; border-radius: 2px;
background: var(--surface-2); color: var(--muted-strong);
border: 1px solid var(--border);
margin: 0 1px;
}
.dock-subtle {
position: fixed; bottom: 24px; right: 24px;
width: 50px; height: 50px;
border-radius: 50%; border: 1px solid var(--border);
background: var(--surface);
display: flex; align-items: center; justify-content: center;
box-shadow: 0 6px 20px var(--shadow-strong);
cursor: pointer;
z-index: 10;
}
.dock-subtle-count {
position: absolute; top: -4px; right: -4px;
width: 18px; height: 18px; border-radius: 50%;
background: var(--accent); color: var(--accent-on);
font-size: 10px; font-weight: 600;
display: flex; align-items: center; justify-content: center;
font-family: var(--font-mono);
}
.dock-header {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.dock-name { font-family: var(--font-display); font-weight: 600; font-size: 12.5px; }
.dock-status { display: flex; align-items: center; gap: 4px; font-size: 10px; color: var(--muted); font-family: var(--font-mono); }
.dock-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 2px var(--ok-soft); }
.dock-section { padding: 8px 10px; border-bottom: 1px solid var(--border); }
.dock-section:last-child { border-bottom: none; }
.dock-section-label {
text-transform: uppercase;
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.10em;
color: var(--muted); margin-bottom: 6px;
}
.dock-section-label-2 { margin-top: 16px; }
.dock-thread { display: flex; flex-direction: column; gap: 8px; }
.bubble-socrates {
background: var(--bubble-socrates);
color: var(--prose);
border-left: 2px solid var(--accent);
border-radius: 2px 8px 8px 2px;
padding-left: 10px;
}
.bubble-socrates .bubble-sigil {
display: inline-block;
font-family: var(--font-display); font-weight: 600;
color: var(--accent); margin-right: 6px;
flex-shrink: 0;
}
.bubble-user {
background: var(--bubble-user);
color: var(--fg);
align-self: flex-end;
max-width: 88%;
}
.dock-input {
display: flex; align-items: center; gap: 6px;
padding: 7px 10px;
border: 1px solid var(--border-strong);
border-radius: 6px;
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--muted);
background: var(--bg);
}
.dock-input-prompt { color: var(--accent); }
.dock-input-placeholder { flex: 1; }
.dock-input-shortcut { font-size: 10px; opacity: 0.55; }
/* Proposal */
.proposal {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px;
}
.proposal-title {
font-family: var(--font-display); font-weight: 600; font-size: 12.5px;
color: var(--fg); margin-bottom: 8px;
line-height: 1.35;
}
.proposal-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.proposal-row { display: flex; gap: 8px; font-family: var(--font-mono); font-size: 10.5px; align-items: baseline; }
.proposal-key { color: var(--muted); width: 70px; flex-shrink: 0; }
.proposal-val { display: flex; gap: 4px; flex-wrap: wrap; }
.proposal-pill {
background: var(--accent-soft); color: var(--accent-strong);
padding: 1px 6px; border-radius: 3px; font-size: 10.5px;
}
.proposal-pill-req { background: var(--c-req-bg); color: var(--c-req-fg); }
.proposal-actions { display: flex; gap: 6px; }
.btn {
flex: 1;
padding: 6px 10px;
border-radius: 5px;
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--fg);
font-family: var(--font-mono);
font-size: 11.5px;
cursor: pointer;
}
.btn:hover { background: var(--surface-2); }
.btn-primary { background: var(--accent); color: var(--accent-on); border-color: var(--accent); }
.btn-primary:hover { background: var(--accent-strong); }
.btn-ghost { background: transparent; border-color: transparent; color: var(--muted); flex: 0; padding: 6px 8px; }
/* Watch list */
.watch { display: flex; flex-direction: column; gap: 6px; }
.watch-row {
display: flex; gap: 8px; align-items: flex-start;
font-size: 11.5px; line-height: 1.4; color: var(--prose);
}
.watch-tag {
font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.04em;
background: var(--c-prop-bg); color: var(--c-prop-fg);
padding: 2px 5px; border-radius: 3px;
flex-shrink: 0; margin-top: 1px;
}
.watch-tag-risk { background: var(--warn-soft); color: var(--warn-strong); }
.watch-validated .watch-tag { background: var(--ok-soft); color: var(--ok-strong); }
.watch-text { flex: 1; }
.watch-risk-high .watch-text { color: var(--fg); }
/* ─── Status bar ─── */
.statusbar {
height: 24px; flex-shrink: 0;
display: flex; align-items: center; gap: 12px;
padding: 0 16px;
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
border-top: 1px solid var(--border);
background: var(--surface);
}
.status-spacer { flex: 1; }
/* ─── Sigil ─── */
.sigil { position: relative; }
.sigil-pulse {
animation: sigilpulse 2.4s ease-in-out infinite;
transform-origin: center;
}
@keyframes sigilpulse {
0%, 100% { opacity: 0.3; r: 2; }
50% { opacity: 1; r: 3.5; }
}
/* ─── Seed screen ─── */
.seed-screen {
width: 100%; height: 100dvh; min-height: 0;
display: flex; flex-direction: column;
background: var(--bg);
font-family: var(--font-body);
color: var(--fg);
overflow: hidden;
}
.seed-top {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
.seed-top-left { display: flex; align-items: center; gap: 10px; }
.seed-brand { font-family: var(--font-display); font-weight: 600; font-size: 16px; }
.seed-pip { color: var(--muted); }
.seed-step { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); }
.seed-top-right { display: flex; gap: 2px; background: var(--surface-2); padding: 2px; border-radius: 5px; }
.seed-mode-pill {
font-family: var(--font-mono); font-size: 11px; padding: 4px 12px;
border-radius: 4px; color: var(--muted); cursor: pointer;
}
.seed-mode-active { background: var(--surface); color: var(--fg); box-shadow: 0 1px 2px var(--shadow); }
.seed-body {
flex: 1;
display: grid;
grid-template-columns: minmax(280px, 380px) 1fr;
min-height: 0;
}
.seed-left {
border-right: 1px solid var(--border);
background: var(--surface);
padding: 22px 22px 18px;
overflow-y: auto;
}
.seed-section-label {
text-transform: uppercase;
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.14em;
color: var(--muted); margin-bottom: 12px;
}
.seed-section-label-2 { margin-top: 24px; }
.seed-fields { display: flex; flex-direction: column; gap: 14px; }
.seed-field {
padding-left: 12px;
border-left: 2px solid var(--border-strong);
}
.seed-field-inferred { border-left-color: var(--accent); border-left-style: dashed; }
.seed-field-label {
font-family: var(--font-mono); font-size: 10.5px;
color: var(--muted); letter-spacing: 0.04em;
margin-bottom: 3px;
display: flex; gap: 8px; align-items: baseline;
}
.seed-conf { font-size: 9.5px; color: var(--accent); opacity: 0.85; }
.seed-field-value {
font-family: var(--font-prose);
font-size: 13.5px; line-height: 1.5;
color: var(--fg);
text-wrap: pretty;
}
.seed-mini-graph {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
padding: 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
position: relative;
}
.mini-block {
border: 1px solid var(--block-border);
background: var(--block-bg);
padding: 8px 10px;
border-radius: 6px;
display: flex; flex-direction: column; gap: 1px;
}
.mini-block-3 { background: var(--block-constraint-bg); border-style: dashed; }
.mini-block-focus {
border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft);
}
.mini-stereo { font-family: var(--font-mono); font-size: 9.5px; color: var(--muted); letter-spacing: 0.04em; }
.mini-name { font-family: var(--font-display); font-weight: 600; font-size: 13px; }
.mini-prop { font-family: var(--font-mono); font-size: 11px; color: var(--muted-strong); margin-top: 2px; }
.mini-edge {
height: 14px;
background: linear-gradient(to bottom, var(--edge), var(--edge)) center/1px 100% no-repeat;
margin-left: 18px;
position: relative;
}
.mini-edge::after {
content: ""; position: absolute; left: -3px; bottom: 0;
border-left: 4px solid transparent; border-right: 4px solid transparent;
border-top: 5px solid var(--edge);
}
.seed-confidence { margin-top: 18px; }
.seed-confidence-row {
display: flex; justify-content: space-between;
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
margin-bottom: 5px;
}
.seed-confidence-bar {
height: 4px; border-radius: 2px;
background: var(--surface-2);
overflow: hidden;
}
.seed-confidence-fill {
height: 100%; background: var(--accent);
}
.seed-confidence-hint {
margin-top: 6px;
font-size: 11px; color: var(--muted); font-family: var(--font-prose);
font-style: italic;
}
.seed-right {
display: flex; flex-direction: column;
min-height: 0;
background: var(--bg);
}
.seed-thread {
flex: 1; min-height: 0;
overflow-y: auto;
padding: 22px 36px;
display: flex; flex-direction: column; gap: 18px;
max-width: 760px;
margin: 0 auto;
width: 100%;
}
.seed-bubble {
display: flex; gap: 14px;
}
.seed-bubble-user { justify-content: flex-end; }
.seed-bubble-avatar { flex-shrink: 0; padding-top: 2px; }
.seed-bubble-body { max-width: 78%; }
.seed-bubble-user .seed-bubble-body {
background: var(--bubble-user);
padding: 10px 14px;
border-radius: 12px 12px 2px 12px;
color: var(--fg);
}
.seed-bubble-who {
font-family: var(--font-mono); font-size: 10.5px;
color: var(--muted); letter-spacing: 0.04em;
text-transform: uppercase; margin-bottom: 4px;
}
.seed-bubble-user .seed-bubble-who { display: none; }
.seed-bubble-text {
font-family: var(--font-prose);
font-size: 14.5px; line-height: 1.6;
color: var(--prose);
text-wrap: pretty;
}
.seed-bubble-user .seed-bubble-text { color: var(--fg); }
.seed-bubble-typing .seed-bubble-text { color: var(--muted); }
.seed-typing { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); font-family: var(--font-mono); }
.seed-typing span {
width: 4px; height: 4px; border-radius: 50%;
background: var(--accent); opacity: 0.35;
animation: typing 1.4s ease-in-out infinite;
}
.seed-typing span:nth-child(2) { animation-delay: 0.2s; }
.seed-typing span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-2px); }
}
.seed-input-row {
border-top: 1px solid var(--border);
background: var(--surface);
padding: 14px 36px;
max-width: 760px; margin: 0 auto; width: 100%;
display: flex; flex-direction: column; gap: 10px;
flex-shrink: 0;
}
.seed-input {
display: flex; align-items: baseline; gap: 8px;
padding: 10px 14px;
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 8px;
font-family: var(--font-prose);
font-size: 14px;
line-height: 1.5;
color: var(--fg);
}
.seed-input-prompt { color: var(--accent); font-family: var(--font-mono); }
.seed-input-text { flex: 1; }
.seed-input-caret {
display: inline-block;
width: 1.5px; height: 16px;
background: var(--accent);
animation: caret 1s steps(1) infinite;
vertical-align: -3px;
}
@keyframes caret { 50% { opacity: 0; } }
.seed-input-actions { display: flex; justify-content: flex-end; gap: 8px; }
.seed-btn {
padding: 6px 12px;
border-radius: 5px;
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--fg);
font-family: var(--font-mono);
font-size: 11.5px;
cursor: pointer;
}
.seed-btn-primary { background: var(--accent); color: var(--accent-on); border-color: var(--accent); }
/* ─── Slash menu (M2) ─── */
.slash-menu {
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: 6px;
box-shadow: 0 8px 24px var(--shadow-strong);
padding: 4px;
min-width: 280px;
display: flex; flex-direction: column; gap: 2px;
}
.slash-menu-empty {
padding: 10px 12px;
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--muted);
}
.slash-menu-item {
display: grid;
grid-template-columns: 20px 1fr auto auto;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
font-family: inherit;
color: var(--fg);
}
.slash-menu-item:hover,
.slash-menu-item-active {
background: var(--accent-soft);
color: var(--accent-strong);
}
.slash-menu-glyph {
font-family: var(--font-mono);
font-size: 13px;
color: var(--muted);
text-align: center;
}
.slash-menu-glyph-block { color: var(--c-block-fg); }
.slash-menu-glyph-property { color: var(--c-prop-fg); }
.slash-menu-glyph-association { color: var(--c-assoc-fg); }
.slash-menu-glyph-requirement { color: var(--c-req-fg); }
.slash-menu-label {
font-family: var(--font-display);
font-size: 13px;
font-weight: 500;
}
.slash-menu-hint {
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--muted);
margin-right: 4px;
}
.slash-menu-key {
font-family: var(--font-mono);
font-size: 10px;
background: var(--surface-2);
color: var(--muted-strong);
border: 1px solid var(--border);
padding: 1px 5px;
border-radius: 3px;
}
/* TipTap-rendered narrative root */
.tiptap { outline: none; }
.tiptap:focus { outline: none; }
.tiptap p.t-p,
.tiptap > p {
margin: 0 0 14px 0;
font-size: 14.5px;
line-height: 1.65;
color: var(--prose);
text-wrap: pretty;
}
.tiptap > h1 {
font-family: var(--font-display);
font-weight: 600;
font-size: 22px;
margin: 14px 0 16px 0;
}
.tiptap > h2 {
font-family: var(--font-display);
font-weight: 600;
font-size: 15px;
margin: 24px 0 8px 0;
}
/* Make ProseMirror's inline-atom selection look like our chip-focus state. */
.tiptap .ProseMirror-selectednode .chip {
box-shadow: 0 0 0 1.5px var(--accent);
}

336
apps/web/styles/diagram.css Normal file
View File

@@ -0,0 +1,336 @@
/* React Flow styling overrides for the Manuscript theme. */
.diagram-flow {
width: 100%;
height: 100%;
background: var(--diagram-bg);
}
/* Hide the default attribution badge for cleaner aesthetic */
.react-flow__attribution {
display: none;
}
.react-flow__pane {
cursor: default;
}
/* Background dots */
.react-flow__background {
background-color: var(--diagram-bg);
}
/* Connection line drawn while drag-creating an edge */
.react-flow__connection-path {
stroke: var(--accent);
stroke-width: 1.5;
stroke-dasharray: 4 3;
}
/* Selection box */
.react-flow__nodesselection-rect,
.react-flow__selection {
background: var(--accent-soft);
border: 1px dashed var(--accent);
}
/* Node base wrapper — we draw our own card; React Flow's outer just provides position. */
.react-flow__node {
font-family: var(--font-body);
cursor: grab;
}
.react-flow__node:active { cursor: grabbing; }
/* Custom node card (BlockNode component) */
.sysml-node {
position: relative;
background: var(--block-bg);
border: 1px solid var(--block-border);
border-radius: 8px;
box-shadow: 0 2px 4px var(--shadow);
font-family: var(--font-body);
user-select: none;
display: flex; flex-direction: column;
min-width: 168px;
}
.sysml-node-actor {
background: var(--block-actor-bg);
}
.sysml-node-constraint {
background: var(--block-constraint-bg);
border-style: dashed;
}
.sysml-node-system {
border-color: var(--accent);
border-width: 1.5px;
}
.sysml-node-selected,
.react-flow__node.selected .sysml-node {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft), 0 2px 4px var(--shadow);
}
.sysml-node-stereo {
font-family: var(--font-mono);
font-size: 9px;
color: var(--muted);
letter-spacing: 0.04em;
padding: 6px 12px 0 12px;
}
.sysml-node-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 13px;
color: var(--fg);
padding: 1px 12px 8px 12px;
}
.sysml-node-divider {
height: 1px;
background: var(--block-divider);
}
.sysml-node-props {
padding: 6px 12px 8px 12px;
display: flex; flex-direction: column; gap: 3px;
}
.sysml-node-prop {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--muted-strong);
}
.sysml-node-prop-empty {
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--muted);
font-style: italic;
}
.sysml-node-constraint-body {
padding: 4px 12px 8px 12px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted-strong);
}
/* Connection handles — kept subtle; visible on hover */
.react-flow__handle {
width: 8px;
height: 8px;
background: var(--surface);
border: 1.25px solid var(--accent);
opacity: 0;
transition: opacity 0.15s;
}
.react-flow__node:hover .react-flow__handle,
.react-flow__node.selected .react-flow__handle {
opacity: 1;
}
/* Edges */
.react-flow__edge-path {
stroke: var(--edge);
stroke-width: 1;
}
.react-flow__edge.selected .react-flow__edge-path {
stroke: var(--accent);
stroke-width: 1.5;
}
.react-flow__edge-text {
font-family: var(--font-mono);
font-size: 10.5px;
fill: var(--edge-label);
}
.react-flow__edge-textbg {
fill: var(--diagram-bg);
}
.sysml-edge-label {
background: var(--diagram-bg);
border: 0.75px solid var(--edge-soft);
border-radius: 4px;
padding: 1px 6px;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--edge-label);
white-space: nowrap;
pointer-events: all;
}
/* Controls (zoom in/out/fit) — positioned via React Flow's `position` prop */
.react-flow__controls {
box-shadow: 0 2px 8px var(--shadow);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.react-flow__controls-button {
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
color: var(--muted-strong);
width: 26px;
height: 26px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.react-flow__controls-button:last-child { border-bottom: none; }
.react-flow__controls-button:hover {
background: var(--surface-2);
color: var(--fg);
}
.react-flow__controls-button svg {
fill: currentColor;
width: 11px;
height: 11px;
max-width: 11px;
max-height: 11px;
}
/* Palette — drag source for new blocks */
.diagram-palette {
position: absolute;
top: 12px;
left: 12px;
display: flex; flex-direction: column; gap: 6px;
z-index: 5;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px;
box-shadow: 0 2px 8px var(--shadow);
}
.diagram-palette-label {
font-family: var(--font-mono);
font-size: 9.5px;
color: var(--muted);
letter-spacing: 0.10em;
text-transform: uppercase;
padding: 2px 4px 4px 4px;
}
.diagram-palette-item {
display: flex; align-items: center; gap: 8px;
padding: 5px 8px;
border-radius: 4px;
background: var(--bg);
border: 1px solid var(--border);
cursor: grab;
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg);
}
.diagram-palette-item:hover {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent-strong);
}
.diagram-palette-item:active { cursor: grabbing; }
.diagram-palette-glyph {
width: 14px; height: 14px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 11px;
color: var(--muted);
}
/* Inspector panel for selected node */
.diagram-inspector {
position: absolute;
top: 12px;
right: 12px;
width: 240px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
box-shadow: 0 2px 8px var(--shadow);
z-index: 5;
display: flex; flex-direction: column; gap: 8px;
font-family: var(--font-body);
}
.diagram-inspector-row {
display: flex; flex-direction: column; gap: 3px;
}
.diagram-inspector-label {
font-family: var(--font-mono);
font-size: 9.5px;
color: var(--muted);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.diagram-inspector-input {
font-family: inherit;
font-size: 13px;
padding: 5px 7px;
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 4px;
color: var(--fg);
outline: none;
}
.diagram-inspector-input:focus { border-color: var(--accent); }
.diagram-inspector-select {
font-family: var(--font-mono);
font-size: 11.5px;
padding: 4px 6px;
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 4px;
color: var(--fg);
}
.diagram-inspector-prop {
display: flex; gap: 4px; align-items: center;
}
.diagram-inspector-prop input {
flex: 1;
font-family: var(--font-mono);
font-size: 11.5px;
padding: 3px 6px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
color: var(--fg);
outline: none;
}
.diagram-inspector-prop input:focus { border-color: var(--accent); }
.diagram-inspector-prop-x {
width: 18px; height: 18px;
display: flex; align-items: center; justify-content: center;
background: transparent;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 13px;
}
.diagram-inspector-prop-x:hover { color: var(--warn-strong); }
.diagram-inspector-add {
margin-top: 2px;
padding: 4px 8px;
background: transparent;
border: 1px dashed var(--border-strong);
border-radius: 4px;
color: var(--muted-strong);
font-family: var(--font-mono);
font-size: 10.5px;
cursor: pointer;
}
.diagram-inspector-add:hover {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent-strong);
}
.diagram-inspector-actions {
display: flex; gap: 6px; margin-top: 6px;
}
.diagram-inspector-btn {
flex: 1;
padding: 5px 8px;
font-family: var(--font-mono);
font-size: 11px;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: 4px;
cursor: pointer;
color: var(--fg);
}
.diagram-inspector-btn:hover { background: var(--surface-2); }
.diagram-inspector-btn-danger { color: var(--warn-strong); }
.diagram-inspector-btn-danger:hover { background: var(--warn-soft); }

View File

@@ -0,0 +1,79 @@
/* MANUSCRIPT — warm parchment, scholarly serif, ink-blue accent.
Document-first; Socrates feels like a character in a book. */
.theme-manuscript {
--font-display: "Newsreader", "Source Serif Pro", "Iowan Old Style", Georgia, serif;
--font-prose: "Newsreader", "Source Serif Pro", "Iowan Old Style", Georgia, serif;
--font-body: "Söhne", "Inter Tight", -apple-system, system-ui, sans-serif;
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace;
--tracking: 0;
--bg: #f5efe2; /* parchment */
--surface: #faf5e9;
--surface-2: #ede4cf;
--fg: #2a241a;
--prose: #3d3527;
--muted: #8a7c63;
--muted-strong: #5e533f;
--border: #d9ceb3;
--border-strong: #b8a982;
--accent: #2c4a6b; /* ink blue */
--accent-strong: #1c3550;
--accent-soft: #d6def0;
--accent-on: #faf5e9;
--ok: #5e7a3a;
--ok-soft: #d9e3c7;
--ok-strong: #3d5421;
--warn: #a8551c;
--warn-soft: #f0d9c2;
--warn-strong: #6b3309;
--info: #5b6e8a;
--shadow: rgba(60,40,15,0.08);
--shadow-strong: rgba(60,40,15,0.18);
--grid-dot: rgba(120,90,40,0.18);
--diagram-bg: #f0e9d6;
--block-bg: #fdfaf0;
--block-border: #b8a982;
--block-divider: #d9ceb3;
--block-actor-bg: #f0e6cf;
--block-constraint-bg:#f5dccb;
--edge: #6e5d3d;
--edge-soft: #c8b88f;
--edge-label: #5e533f;
--note-bg: #f0e3c4;
--bubble-socrates: #f0e3c4;
--bubble-user: #ede4cf;
--chip-bg: #ede4cf;
--chip-bg-hover: #e2d6b8;
--chip-fg: #2a241a;
/* color-coded chips */
--c-block-bg: #d6def0; --c-block-fg: #1c3550;
--c-prop-bg: #e8e0c2; --c-prop-fg: #6b5a25;
--c-assoc-bg: #d9e3c7; --c-assoc-fg: #3d5421;
--c-req-bg: #f0d9c2; --c-req-fg: #6b3309;
}
/* Manuscript-only flourishes: drop-cap-ish heading underline */
.theme-manuscript .t-h1 {
border-bottom: 1px solid var(--border-strong);
padding-bottom: 8px;
font-style: italic;
letter-spacing: -0.005em;
}
.theme-manuscript .t-h2 {
font-style: italic;
font-weight: 500;
color: var(--accent-strong);
}
.theme-manuscript .brand-name { font-style: italic; }
.theme-manuscript .canvas-title { font-style: italic; font-weight: 500; }

34
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}