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).
166 lines
6.6 KiB
TypeScript
166 lines
6.6 KiB
TypeScript
// 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";
|
||
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[]>;
|
||
}
|
||
|
||
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] }));
|
||
|
||
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 => {
|
||
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-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>
|
||
|
||
<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">
|
||
{(["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>
|
||
|
||
<button
|
||
className="rail-collapse-btn rail-collapse-btn-bottom"
|
||
onClick={() => setCollapsed(true)}
|
||
title="Collapse rail"
|
||
type="button"
|
||
>
|
||
‹
|
||
</button>
|
||
</nav>
|
||
);
|
||
}
|