MVP M4: SysML metamodel + validator + dependency graph + UI surfacing
apps/web/lib/sysml — pure engine (no UI deps) - model.ts: canonical types ported from phase-0 (Block, Property, Association, Constraint, Requirement, SysMLModel) - validate.ts: 12 rules grouped Structural (S1–S5) / Semantic (M1–M5) / Traceability (T1–T2). Pure function returning element-anchored issues. Cycle detection via DFS with canonical-key dedup. T3 deferred to Phase 1.5 (depends on narrative chip references). - depgraph.ts: directed graph over Blocks / Properties / Constraints / Requirements with `dependentsOf()` and `neighborhood()` for impact analysis (substrate for M7). - fromFixture.ts: loose FixtureData → strict SysMLModel converter (interim until M5 unifies state). - breaks.ts: 8 named model corruptions (S1/S2/S3/M2/M3/M5/T1/T2) for ?break=... demonstrations. UI integration in EditorShell + LeftRail + DiagramCanvas + IssuesPanel - Floating IssuesPanel (bottom-right) with severity-grouped counts and collapse. Click an issue to focus its anchor across the rail and diagram. - LeftRail: severity dots on each block in the Model section and on each requirement entry. Tooltips show full issue text. - DiagramCanvas: red/blue/dotted ring around offending blocks via issuesByElement → BlockNode.data.issueSeverity. - ?break=S1-dangling,M2-cycle,M5-dup-property,... query param injects corruptions for verification of the "done when" criterion. - Suspense boundary added at the editor route for useSearchParams. Two pre-existing TipTap typing fixes (editor.storage cast through unknown).
This commit is contained in:
@@ -1,19 +1,25 @@
|
||||
// The dual-canvas workspace shell.
|
||||
// Composes TopBar / SocratesDock / LeftRail / TextCanvas / DiagramCanvas / StatusBar.
|
||||
// Ported from docs/design-source/socrata/project/editor-shell.jsx (EditorShell).
|
||||
// M4 adds: SysML model derivation + validation, IssuesPanel, optional ?break=...
|
||||
// query param to demonstrate the validator surfacing rule violations.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { LeftRail } from "./LeftRail";
|
||||
import { CanvasHeader } from "./CanvasHeader";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { IssuesPanel } from "./IssuesPanel";
|
||||
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";
|
||||
import { fromFixture } from "../../lib/sysml/fromFixture";
|
||||
import { validate, type ValidationIssue } from "../../lib/sysml/validate";
|
||||
import { applyBreaks, BREAKS, type BreakName } from "../../lib/sysml/breaks";
|
||||
|
||||
interface EditorShellProps {
|
||||
data: FixtureData;
|
||||
@@ -31,6 +37,33 @@ export function EditorShell({
|
||||
presence = "default",
|
||||
}: EditorShellProps) {
|
||||
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Parse `?break=S1-dangling,M2-cycle&...` into a list of named corruptions.
|
||||
const breaks = useMemo<BreakName[]>(() => {
|
||||
const raw = searchParams?.get("break") ?? "";
|
||||
if (!raw) return [];
|
||||
return raw
|
||||
.split(",")
|
||||
.map(s => s.trim())
|
||||
.filter((s): s is BreakName => s in BREAKS);
|
||||
}, [searchParams]);
|
||||
|
||||
// Derive the SysML model from the fixture, optionally apply breaks, validate.
|
||||
const { issues, issuesByElement } = useMemo(() => {
|
||||
const baseModel = fromFixture(data);
|
||||
const broken = breaks.length > 0 ? applyBreaks(baseModel, breaks) : baseModel;
|
||||
const issues = validate(broken);
|
||||
const issuesByElement = new Map<string, ValidationIssue[]>();
|
||||
for (const i of issues) {
|
||||
const key = anchorKey(i.anchor);
|
||||
if (!key) continue;
|
||||
const prev = issuesByElement.get(key) ?? [];
|
||||
prev.push(i);
|
||||
issuesByElement.set(key, prev);
|
||||
}
|
||||
return { issues, issuesByElement };
|
||||
}, [data, breaks]);
|
||||
|
||||
return (
|
||||
<div className={`shell shell-density-${density} shell-presence-${presence}`}>
|
||||
@@ -38,7 +71,12 @@ export function EditorShell({
|
||||
|
||||
<div className="shell-body">
|
||||
<SocratesDock thread={data.socratesThread} presence={presence} density={density} />
|
||||
<LeftRail data={data} focusBlockId={focusBlockId} setFocusBlockId={setFocusBlockId} />
|
||||
<LeftRail
|
||||
data={data}
|
||||
focusBlockId={focusBlockId}
|
||||
setFocusBlockId={setFocusBlockId}
|
||||
issuesByElement={issuesByElement}
|
||||
/>
|
||||
|
||||
<main className="canvases">
|
||||
<section className="canvas canvas-text">
|
||||
@@ -59,7 +97,9 @@ export function EditorShell({
|
||||
<section className="canvas canvas-diagram">
|
||||
<CanvasHeader
|
||||
title="Model"
|
||||
subtitle="SysML · 6 blocks · 6 associations · 1 constraint"
|
||||
subtitle={breaks.length > 0
|
||||
? `SysML · breaks active: ${breaks.join(", ")}`
|
||||
: "SysML · 6 blocks · 6 associations · 1 constraint"}
|
||||
right={
|
||||
<div className="canvas-actions">
|
||||
<span className="canvas-mode-pill">Fit</span>
|
||||
@@ -75,6 +115,7 @@ export function EditorShell({
|
||||
variant={diagramStyle}
|
||||
focusBlockId={focusBlockId}
|
||||
onSelect={setFocusBlockId}
|
||||
issuesByElement={issuesByElement}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -82,6 +123,22 @@ export function EditorShell({
|
||||
</div>
|
||||
|
||||
<StatusBar data={data} />
|
||||
|
||||
<IssuesPanel issues={issues} onSelectAnchor={id => setFocusBlockId(id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Map an issue's anchor to a single string key matching either a block id or
|
||||
* a synthetic key (`assoc:a1`, `req:req-001`, `constraint:ferpa`). The UI uses
|
||||
* block ids most often, so block anchors return the bare id. */
|
||||
function anchorKey(anchor: ValidationIssue["anchor"]): string | null {
|
||||
switch (anchor.kind) {
|
||||
case "block": return anchor.id;
|
||||
case "association": return `assoc:${anchor.id}`;
|
||||
case "constraint": return `constraint:${anchor.id}`;
|
||||
case "requirement": return `req:${anchor.id}`;
|
||||
case "property": return anchor.blockId;
|
||||
case "model": return null;
|
||||
}
|
||||
}
|
||||
|
||||
73
apps/web/components/editor/IssuesPanel.tsx
Normal file
73
apps/web/components/editor/IssuesPanel.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
// Floating panel listing current validation issues — shown next to the
|
||||
// status bar. Click an issue to focus its anchored element across the rail
|
||||
// + diagram (via setFocusBlockId). Lets the user verify the validator is
|
||||
// actually firing on the broken-fixture demos.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ValidationIssue } from "../../lib/sysml/validate";
|
||||
|
||||
interface IssuesPanelProps {
|
||||
issues: ValidationIssue[];
|
||||
onSelectAnchor?: (id: string) => void;
|
||||
}
|
||||
|
||||
const SEV_GLYPH: Record<ValidationIssue["severity"], string> = {
|
||||
error: "●",
|
||||
warning: "▲",
|
||||
soft: "·",
|
||||
};
|
||||
|
||||
export function IssuesPanel({ issues, onSelectAnchor }: IssuesPanelProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const grouped = {
|
||||
error: issues.filter(i => i.severity === "error"),
|
||||
warning: issues.filter(i => i.severity === "warning"),
|
||||
soft: issues.filter(i => i.severity === "soft"),
|
||||
};
|
||||
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<div className={`issues-panel issues-panel-clean ${collapsed ? "issues-panel-collapsed" : ""}`}>
|
||||
<button className="issues-panel-head" onClick={() => setCollapsed(c => !c)} type="button">
|
||||
<span className="issues-panel-clean-glyph">✓</span>
|
||||
<span className="issues-panel-title">Model is clean</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`issues-panel ${collapsed ? "issues-panel-collapsed" : ""}`}>
|
||||
<button className="issues-panel-head" onClick={() => setCollapsed(c => !c)} type="button">
|
||||
<span className="issues-panel-counts">
|
||||
{grouped.error.length > 0 && <span className="issues-count issues-count-error">● {grouped.error.length}</span>}
|
||||
{grouped.warning.length > 0 && <span className="issues-count issues-count-warning">▲ {grouped.warning.length}</span>}
|
||||
{grouped.soft.length > 0 && <span className="issues-count issues-count-soft">· {grouped.soft.length}</span>}
|
||||
</span>
|
||||
<span className="issues-panel-title">{issues.length} validation issue{issues.length === 1 ? "" : "s"}</span>
|
||||
<span className="issues-panel-caret">{collapsed ? "▴" : "▾"}</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<ul className="issues-panel-list">
|
||||
{(["error", "warning", "soft"] as const).flatMap(sev =>
|
||||
grouped[sev].map((i, idx) => (
|
||||
<li
|
||||
key={`${sev}-${idx}`}
|
||||
className={`issues-item issues-item-${i.severity}`}
|
||||
onClick={() => onSelectAnchor?.(i.anchor.kind === "property" ? i.anchor.blockId : (i.anchor as { id: string }).id ?? "")}
|
||||
>
|
||||
<span className="issues-item-sev">{SEV_GLYPH[i.severity]}</span>
|
||||
<span className="issues-item-code">{i.code}</span>
|
||||
<span className="issues-item-msg">{i.message}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,14 +6,30 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import type { FixtureData } from "../../lib/fixtures/aristotle";
|
||||
import type { ValidationIssue, Severity } from "../../lib/sysml/validate";
|
||||
|
||||
interface LeftRailProps {
|
||||
data: FixtureData;
|
||||
focusBlockId: string | null;
|
||||
setFocusBlockId: (id: string | null) => void;
|
||||
/** Map keyed by element key (block id, `req:<id>`, `assoc:<id>`, …) → issues. */
|
||||
issuesByElement?: Map<string, ValidationIssue[]>;
|
||||
}
|
||||
|
||||
export function LeftRail({ data, focusBlockId, setFocusBlockId }: LeftRailProps) {
|
||||
function maxSeverityForKey(map: Map<string, ValidationIssue[]> | undefined, key: string): Severity | null {
|
||||
const items = map?.get(key);
|
||||
if (!items || items.length === 0) return null;
|
||||
if (items.some(i => i.severity === "error")) return "error";
|
||||
if (items.some(i => i.severity === "warning")) return "warning";
|
||||
return "soft";
|
||||
}
|
||||
|
||||
function IssueDot({ severity, title }: { severity: Severity | null; title?: string }) {
|
||||
if (!severity) return null;
|
||||
return <span className={`rail-issue-dot rail-issue-dot-${severity}`} title={title} />;
|
||||
}
|
||||
|
||||
export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement }: 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] }));
|
||||
@@ -80,26 +96,31 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId }: LeftRailProps)
|
||||
</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"}`}
|
||||
{data.blocks.map(b => {
|
||||
const sev = maxSeverityForKey(issuesByElement, b.id);
|
||||
const tooltip = issuesByElement?.get(b.id)?.map(i => `${i.code}: ${i.message}`).join("\n");
|
||||
return (
|
||||
<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-count-glyph">·</span>
|
||||
{b.properties.length}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
<span className="rail-block-glyph">
|
||||
{b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : "▢"}
|
||||
</span>
|
||||
<span className="rail-block-label">{b.label}</span>
|
||||
<IssueDot severity={sev} title={tooltip} />
|
||||
<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>
|
||||
@@ -111,18 +132,22 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId }: LeftRailProps)
|
||||
</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>
|
||||
{(["req-001", "req-002", "req-003"] as const).map(reqId => {
|
||||
const tag = reqId.toUpperCase().replace("-", "-");
|
||||
const sev = maxSeverityForKey(issuesByElement, `req:${reqId}`);
|
||||
const tooltip = issuesByElement?.get(`req:${reqId}`)?.map(i => `${i.code}: ${i.message}`).join("\n");
|
||||
const isUntraced = sev === "warning"; // T1 is the warning we surface here
|
||||
return (
|
||||
<li key={reqId} className="rail-req">
|
||||
<span className="req-tag">{tag.toUpperCase()}</span>
|
||||
{sev ? (
|
||||
<IssueDot severity={sev} title={tooltip} />
|
||||
) : (
|
||||
<span className={`req-status ${isUntraced ? "req-untraced" : "req-traced"}`} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user