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:
41
apps/web/.gitignore
vendored
Normal file
41
apps/web/.gitignore
vendored
Normal 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
36
apps/web/README.md
Normal 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.
|
||||
8
apps/web/app/editor/[projectId]/page.tsx
Normal file
8
apps/web/app/editor/[projectId]/page.tsx
Normal 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.
|
||||
// M4–M5 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
30
apps/web/app/layout.tsx
Normal 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
50
apps/web/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/app/seed/page.tsx
Normal file
5
apps/web/app/seed/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SeedScreen } from "../../components/seed/SeedScreen";
|
||||
|
||||
export default function SeedPage() {
|
||||
return <SeedScreen />;
|
||||
}
|
||||
203
apps/web/components/diagram-canvas/DiagramCanvas.tsx
Normal file
203
apps/web/components/diagram-canvas/DiagramCanvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
apps/web/components/diagram-canvas/NodeInspector.tsx
Normal file
140
apps/web/components/diagram-canvas/NodeInspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/web/components/diagram-canvas/Palette.tsx
Normal file
43
apps/web/components/diagram-canvas/Palette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
apps/web/components/diagram-canvas/edges/SysmlEdge.tsx
Normal file
61
apps/web/components/diagram-canvas/edges/SysmlEdge.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
apps/web/components/diagram-canvas/fixtureToFlow.ts
Normal file
47
apps/web/components/diagram-canvas/fixtureToFlow.ts
Normal 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 };
|
||||
}
|
||||
67
apps/web/components/diagram-canvas/nodes/BlockNode.tsx
Normal file
67
apps/web/components/diagram-canvas/nodes/BlockNode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/web/components/editor/CanvasHeader.tsx
Normal file
22
apps/web/components/editor/CanvasHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
apps/web/components/editor/EditorShell.tsx
Normal file
87
apps/web/components/editor/EditorShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
apps/web/components/editor/LeftRail.tsx
Normal file
140
apps/web/components/editor/LeftRail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
apps/web/components/editor/StatusBar.tsx
Normal file
20
apps/web/components/editor/StatusBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/web/components/editor/TopBar.tsx
Normal file
36
apps/web/components/editor/TopBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
apps/web/components/seed/SeedScreen.tsx
Normal file
185
apps/web/components/seed/SeedScreen.tsx
Normal 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 3–10 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 3–10. 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 3–10. 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>
|
||||
);
|
||||
}
|
||||
42
apps/web/components/socrates/Sigil.tsx
Normal file
42
apps/web/components/socrates/Sigil.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
apps/web/components/socrates/SocratesDock.tsx
Normal file
82
apps/web/components/socrates/SocratesDock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
apps/web/components/text-canvas/Chip.tsx
Normal file
71
apps/web/components/text-canvas/Chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
apps/web/components/text-canvas/ChipNode.ts
Normal file
82
apps/web/components/text-canvas/ChipNode.ts
Normal 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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
73
apps/web/components/text-canvas/ChipView.tsx
Normal file
73
apps/web/components/text-canvas/ChipView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
apps/web/components/text-canvas/FocusContext.tsx
Normal file
23
apps/web/components/text-canvas/FocusContext.tsx
Normal 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);
|
||||
}
|
||||
20
apps/web/components/text-canvas/SlashExtension.ts
Normal file
20
apps/web/components/text-canvas/SlashExtension.ts
Normal 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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
110
apps/web/components/text-canvas/SlashMenu.tsx
Normal file
110
apps/web/components/text-canvas/SlashMenu.tsx
Normal 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: 1–4 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>
|
||||
);
|
||||
});
|
||||
94
apps/web/components/text-canvas/TextCanvas.tsx
Normal file
94
apps/web/components/text-canvas/TextCanvas.tsx
Normal 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">
|
||||
“Refuses to produce solutions” is a strong constraint. Have you decided what counts as a “solution” vs. a “scaffold”? This boundary will determine whether the refusal policy is enforceable.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</ChipFocusContext.Provider>
|
||||
);
|
||||
}
|
||||
43
apps/web/components/text-canvas/fixtureToDoc.ts
Normal file
43
apps/web/components/text-canvas/fixtureToDoc.ts
Normal 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 };
|
||||
}
|
||||
92
apps/web/components/text-canvas/slashSuggestion.ts
Normal file
92
apps/web/components/text-canvas/slashSuggestion.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
18
apps/web/eslint.config.mjs
Normal file
18
apps/web/eslint.config.mjs
Normal 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;
|
||||
202
apps/web/lib/fixtures/aristotle.ts
Normal file
202
apps/web/lib/fixtures/aristotle.ts
Normal 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 4–6 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
7
apps/web/next.config.ts
Normal 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
31
apps/web/package.json
Normal 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
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
925
apps/web/styles/base.css
Normal 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
336
apps/web/styles/diagram.css
Normal 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); }
|
||||
79
apps/web/styles/theme-manuscript.css
Normal file
79
apps/web/styles/theme-manuscript.css
Normal 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
34
apps/web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user