@@ -96,7 +128,7 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement
{open.model && (
- {data.blocks.map(b => {
+ {modelEntries.map(b => {
const sev = maxSeverityForKey(issuesByElement, b.id);
const tooltip = issuesByElement?.get(b.id)?.map(i => `${i.code}: ${i.message}`).join("\n");
return (
@@ -105,19 +137,23 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement
className={`rail-block rail-${b.kind} ${focusBlockId === b.id ? "rail-block-active" : ""}`}
onMouseEnter={() => setFocusBlockId(b.id)}
onMouseLeave={() => setFocusBlockId(null)}
+ onClick={() => setFocusBlockId(b.id)}
+ style={{ cursor: "pointer" }}
>
- {b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : "▢"}
+ {b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : b.kind === "system" ? "◎" : "▢"}
{b.label}
-
- ·
- {b.properties.length}
-
+ {b.kind !== "constraint" && (
+
+ ·
+ {b.propertyCount}
+
+ )}
);
})}
@@ -132,18 +168,18 @@ export function LeftRail({ data, focusBlockId, setFocusBlockId, issuesByElement
{open.requirements && (
- {(["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
+ {model.requirements.map(r => {
+ const key = `req:${r.id}`;
+ const sev = maxSeverityForKey(issuesByElement, key);
+ const tooltip = issuesByElement?.get(key)?.map(i => `${i.code}: ${i.message}`).join("\n");
+ const traced = r.relations.some(rel => rel.kind === "satisfy");
return (
- -
- {tag.toUpperCase()}
+
-
+ {r.tag}
{sev ? (
) : (
-
+
)}
);
diff --git a/apps/web/components/text-canvas/ChipView.tsx b/apps/web/components/text-canvas/ChipView.tsx
index 4ec09b8..71342c4 100644
--- a/apps/web/components/text-canvas/ChipView.tsx
+++ b/apps/web/components/text-canvas/ChipView.tsx
@@ -1,13 +1,20 @@
// React NodeView for the chip TipTap node.
-// Renders identically to the static Chip via the same CSS classes.
+//
+// M5: chips look up their displayed label from the SysMLModel via refId.
+// Click a chip → opens an inline rename popover that emits an update-block
+// op via useApply(). Renaming here updates every other chip with the same
+// refId AND the diagram block label.
"use client";
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
+import { useEffect, useRef, useState } from "react";
import type { ChipKind } from "../../lib/fixtures/aristotle";
import type { MarkupStyle } from "./Chip";
import { useChipFocus } from "./FocusContext";
+import { useModelStore } from "../../lib/sync/ModelStore";
+import { updateBlock, updateRequirement, updateAssociation } from "../../lib/sync/ops";
const KIND_LABEL: Record = {
block: "block",
@@ -27,30 +34,99 @@ function kindGlyph(kind: ChipKind): string {
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 fallbackLabel = (node.attrs.label as string) ?? "untitled";
+
const markupStyle =
((editor.storage as unknown as Record).markupStyle as MarkupStyle | undefined) ?? "color";
const { focusBlockId, setFocusBlockId } = useChipFocus();
- const isFocused = (refId !== null && refId === focusBlockId) || selected;
+ const { model, apply } = useModelStore();
+
+ // Resolve the live label from the model, falling back to the node's stored
+ // label (e.g. for chips created via slash-menu before they're bound).
+ const liveLabel = useMemo_label(model, kind, refId) ?? fallbackLabel;
+
+ const isFocused = (refId !== null && refId === focusBlockId) || selected;
+ const [isEditing, setIsEditing] = useState(false);
+ const [draft, setDraft] = useState(liveLabel);
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (isEditing) {
+ setDraft(liveLabel);
+ // Allow the input to mount before focusing.
+ requestAnimationFrame(() => {
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ });
+ }
+ }, [isEditing, liveLabel]);
+
+ function commitRename() {
+ setIsEditing(false);
+ const next = draft.trim();
+ if (!next || next === liveLabel || !refId) return;
+ if (kind === "block") {
+ apply([updateBlock(refId, { label: next })]);
+ } else if (kind === "requirement") {
+ apply([updateRequirement(refId, { tag: next })]);
+ } else if (kind === "association") {
+ apply([updateAssociation(refId, { label: next })]);
+ }
+ // property and (potential future) constraint chips: rename in the
+ // model is more involved (need parent block id resolution); skip for M5.
+ }
+
+ const cls = `chip chip-${kind} chip-style-${markupStyle}${isFocused ? " chip-focus" : ""}${isEditing ? " chip-editing" : ""}`;
- const cls = `chip chip-${kind} chip-style-${markupStyle}${isFocused ? " chip-focus" : ""}`;
const handlers = refId
? {
onMouseEnter: () => setFocusBlockId(refId),
- onMouseLeave: () => setFocusBlockId(null),
+ onMouseLeave: () => !isEditing && setFocusBlockId(null),
+ onDoubleClick: (e: React.MouseEvent) => {
+ e.preventDefault();
+ if (refId) setIsEditing(true);
+ },
onClick: () => setFocusBlockId(refId),
}
: {};
+ // Inline rename input — replaces the label visual, preserves the chip frame
+ if (isEditing && refId) {
+ return (
+
+ {markupStyle === "bracket" && [}
+ {markupStyle === "bracket" && {KIND_LABEL[kind]}:}
+ {markupStyle !== "bracket" && {kindGlyph(kind)}}
+ setDraft(e.target.value)}
+ onBlur={commitRename}
+ onKeyDown={e => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ (e.target as HTMLInputElement).blur();
+ } else if (e.key === "Escape") {
+ setDraft(liveLabel);
+ setIsEditing(false);
+ }
+ }}
+ size={Math.max(draft.length, 6)}
+ />
+ {markupStyle === "bracket" && ]}
+
+ );
+ }
+
if (markupStyle === "bracket") {
return (
-
+
[
{KIND_LABEL[kind]}:
- {label}
+ {liveLabel}
]
);
@@ -58,17 +134,54 @@ export function ChipView({ node, selected, editor }: NodeViewProps) {
if (markupStyle === "underline") {
return (
-
+
{kindGlyph(kind)}
- {label}
+ {liveLabel}
);
}
return (
-
+
{kindGlyph(kind)}
- {label}
+ {liveLabel}
);
}
+
+/**
+ * Look up the live label for a chip from the model. Inline-imported and
+ * collocated to keep the resolution rules near the consumer.
+ *
+ * For block / requirement / association / constraint chips we resolve by
+ * matching `refId` against the corresponding element. Property chips use a
+ * composite refId ("blockId.propertyId") — handled with a `.` split.
+ */
+function useMemo_label(model: ReturnType["model"], kind: ChipKind, refId: string | null): string | null {
+ if (!refId) return null;
+ if (kind === "block") {
+ return model.blocks.find(b => b.id === refId)?.label ?? null;
+ }
+ if (kind === "requirement") {
+ return model.requirements.find(r => r.id === refId || r.tag === refId)?.tag ?? null;
+ }
+ if (kind === "association") {
+ return model.associations.find(a => a.id === refId)?.label ?? null;
+ }
+ if (kind === "property") {
+ const dot = refId.indexOf(".");
+ if (dot < 0) {
+ // Bare property name from the fixture; do a flat search.
+ for (const b of model.blocks) {
+ const p = b.properties.find(p => p.name === refId || p.id === refId);
+ if (p) return p.name;
+ }
+ return null;
+ }
+ const blockId = refId.slice(0, dot);
+ const propId = refId.slice(dot + 1);
+ const b = model.blocks.find(x => x.id === blockId);
+ return b?.properties.find(p => p.id === propId || p.name === propId)?.name ?? null;
+ }
+ return null;
+}
diff --git a/apps/web/lib/sync/ModelStore.tsx b/apps/web/lib/sync/ModelStore.tsx
new file mode 100644
index 0000000..64ffa46
--- /dev/null
+++ b/apps/web/lib/sync/ModelStore.tsx
@@ -0,0 +1,83 @@
+// React provider exposing the canonical SysMLModel + applyOps.
+// Used by both canvases (and the rail) so they stay in sync.
+
+"use client";
+
+import { createContext, useCallback, useContext, useMemo, useState } from "react";
+import { applyOps, type ApplyResult } from "./applyOps";
+import { validate, type ValidationIssue } from "../sysml/validate";
+import type { SysMLModel } from "../sysml/model";
+import type { ModelOp } from "./ops";
+
+export interface ModelStoreValue {
+ model: SysMLModel;
+ apply: (ops: ModelOp[]) => ApplyResult;
+ /** Validation issues, recomputed on each successful apply. */
+ issues: ValidationIssue[];
+ issuesByElement: Map;
+}
+
+const ModelStoreContext = createContext(null);
+
+interface ModelStoreProviderProps {
+ initialModel: SysMLModel;
+ children: React.ReactNode;
+}
+
+export function ModelStoreProvider({ initialModel, children }: ModelStoreProviderProps) {
+ const [model, setModel] = useState(initialModel);
+
+ const apply = useCallback((ops: ModelOp[]): ApplyResult => {
+ let result: ApplyResult = { model, idMapping: {}, errors: [], applied: false };
+ setModel(current => {
+ result = applyOps(current, ops);
+ return result.applied ? result.model : current;
+ });
+ return result;
+ }, [model]);
+
+ const { issues, issuesByElement } = useMemo(() => {
+ const issues = validate(model);
+ const issuesByElement = new Map();
+ for (const i of issues) {
+ const k = anchorKey(i.anchor);
+ if (!k) continue;
+ const prev = issuesByElement.get(k) ?? [];
+ prev.push(i);
+ issuesByElement.set(k, prev);
+ }
+ return { issues, issuesByElement };
+ }, [model]);
+
+ const value: ModelStoreValue = useMemo(
+ () => ({ model, apply, issues, issuesByElement }),
+ [model, apply, issues, issuesByElement]
+ );
+
+ return {children};
+}
+
+export function useModelStore(): ModelStoreValue {
+ const ctx = useContext(ModelStoreContext);
+ if (!ctx) throw new Error("useModelStore must be used inside ModelStoreProvider");
+ return ctx;
+}
+
+export function useModel(): SysMLModel {
+ return useModelStore().model;
+}
+
+export function useApply(): (ops: ModelOp[]) => ApplyResult {
+ return useModelStore().apply;
+}
+
+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;
+ }
+}
diff --git a/apps/web/lib/sync/applyOps.ts b/apps/web/lib/sync/applyOps.ts
new file mode 100644
index 0000000..153621f
--- /dev/null
+++ b/apps/web/lib/sync/applyOps.ts
@@ -0,0 +1,261 @@
+// The single chokepoint for SysMLModel mutations.
+//
+// Pure: (model, ops) → { model, idMapping, errors }. No DB / network here.
+// M5 in-memory: tempIds are used as canonical ids when free, otherwise
+// rewritten with a `_n` suffix. The DB layer (later) will replace this with
+// server-issued ids.
+//
+// applyOps is atomic per-batch: if any op fails validation, the entire
+// batch is rejected and the original model is returned unchanged. This is
+// the contract docs/sync.md §4 specifies.
+
+import type { SysMLModel, Block, Association, Constraint, Requirement } from "../sysml/model";
+import type { ModelOp } from "./ops";
+
+export interface ApplyResult {
+ model: SysMLModel;
+ /** Map of any tempId → assigned canonical id. */
+ idMapping: Record;
+ errors: ApplyError[];
+ /** True iff the batch landed without errors. */
+ applied: boolean;
+}
+
+export interface ApplyError {
+ opIndex: number;
+ code: "DUPLICATE_ID" | "MISSING_ELEMENT" | "INVALID_PATCH";
+ message: string;
+}
+
+export function applyOps(model: SysMLModel, ops: ModelOp[]): ApplyResult {
+ const idMapping: Record = {};
+ const errors: ApplyError[] = [];
+
+ // Fail-fast working copy. We mutate this; if errors accumulate we throw it
+ // away and return the original.
+ let next = cloneModel(model);
+
+ for (let i = 0; i < ops.length; i++) {
+ const op = ops[i]!;
+ try {
+ next = applyOne(next, op, idMapping);
+ } catch (err) {
+ errors.push({ opIndex: i, code: classifyError(err), message: (err as Error).message });
+ }
+ }
+
+ if (errors.length > 0) {
+ return { model, idMapping: {}, errors, applied: false };
+ }
+ return { model: next, idMapping, errors: [], applied: true };
+}
+
+// ─── Per-op application ──────────────────────────────────────────────────
+
+function applyOne(model: SysMLModel, op: ModelOp, idMapping: Record): SysMLModel {
+ switch (op.kind) {
+ case "add-block": {
+ const id = resolveAddId(op.block.id, op.tempId, idMapping, model.blocks.map(b => b.id));
+ const block: Block = { ...op.block, id };
+ return { ...model, blocks: [...model.blocks, block] };
+ }
+ case "update-block": {
+ const realId = idMapping[op.blockId] ?? op.blockId;
+ assertExists(model.blocks, realId, "block");
+ return {
+ ...model,
+ blocks: model.blocks.map(b => (b.id === realId ? { ...b, ...op.patch } : b)),
+ };
+ }
+ case "remove-block": {
+ const realId = idMapping[op.blockId] ?? op.blockId;
+ assertExists(model.blocks, realId, "block");
+ // Cascade: drop incident associations + requirement satisfiers + constraint applies-to
+ return {
+ ...model,
+ blocks: model.blocks.filter(b => b.id !== realId),
+ associations: model.associations.filter(a => a.fromBlockId !== realId && a.toBlockId !== realId),
+ constraints: model.constraints.map(c => ({ ...c, appliesTo: c.appliesTo.filter(id => id !== realId) })),
+ requirements: model.requirements.map(r => ({
+ ...r,
+ relations: r.relations.filter(rel => rel.kind !== "satisfy" || rel.blockId !== realId),
+ })),
+ };
+ }
+
+ case "add-property": {
+ const blockId = idMapping[op.blockId] ?? op.blockId;
+ const block = model.blocks.find(b => b.id === blockId);
+ if (!block) throw missing("block", blockId);
+ const propId = resolveAddId(op.property.id, op.tempId, idMapping, block.properties.map(p => p.id));
+ return {
+ ...model,
+ blocks: model.blocks.map(b =>
+ b.id === blockId ? { ...b, properties: [...b.properties, { ...op.property, id: propId }] } : b
+ ),
+ };
+ }
+ case "update-property": {
+ const blockId = idMapping[op.blockId] ?? op.blockId;
+ return {
+ ...model,
+ blocks: model.blocks.map(b =>
+ b.id === blockId
+ ? { ...b, properties: b.properties.map(p => (p.id === op.propertyId ? { ...p, ...op.patch } : p)) }
+ : b
+ ),
+ };
+ }
+ case "remove-property": {
+ const blockId = idMapping[op.blockId] ?? op.blockId;
+ return {
+ ...model,
+ blocks: model.blocks.map(b =>
+ b.id === blockId ? { ...b, properties: b.properties.filter(p => p.id !== op.propertyId) } : b
+ ),
+ };
+ }
+
+ case "add-association": {
+ const id = resolveAddId(op.association.id, op.tempId, idMapping, model.associations.map(a => a.id));
+ const association: Association = {
+ ...op.association,
+ id,
+ fromBlockId: idMapping[op.association.fromBlockId] ?? op.association.fromBlockId,
+ toBlockId: idMapping[op.association.toBlockId] ?? op.association.toBlockId,
+ };
+ return { ...model, associations: [...model.associations, association] };
+ }
+ case "update-association": {
+ const realId = idMapping[op.associationId] ?? op.associationId;
+ assertExists(model.associations, realId, "association");
+ return {
+ ...model,
+ associations: model.associations.map(a => (a.id === realId ? { ...a, ...op.patch } : a)),
+ };
+ }
+ case "remove-association": {
+ const realId = idMapping[op.associationId] ?? op.associationId;
+ assertExists(model.associations, realId, "association");
+ return { ...model, associations: model.associations.filter(a => a.id !== realId) };
+ }
+
+ case "add-constraint": {
+ const id = resolveAddId(op.constraint.id, op.tempId, idMapping, model.constraints.map(c => c.id));
+ const constraint: Constraint = {
+ ...op.constraint,
+ id,
+ appliesTo: op.constraint.appliesTo.map(b => idMapping[b] ?? b),
+ };
+ return { ...model, constraints: [...model.constraints, constraint] };
+ }
+ case "update-constraint": {
+ const realId = idMapping[op.constraintId] ?? op.constraintId;
+ assertExists(model.constraints, realId, "constraint");
+ return {
+ ...model,
+ constraints: model.constraints.map(c => (c.id === realId ? { ...c, ...op.patch } : c)),
+ };
+ }
+ case "remove-constraint": {
+ const realId = idMapping[op.constraintId] ?? op.constraintId;
+ assertExists(model.constraints, realId, "constraint");
+ return { ...model, constraints: model.constraints.filter(c => c.id !== realId) };
+ }
+
+ case "add-requirement": {
+ const id = resolveAddId(op.requirement.id, op.tempId, idMapping, model.requirements.map(r => r.id));
+ const requirement: Requirement = { ...op.requirement, id };
+ return { ...model, requirements: [...model.requirements, requirement] };
+ }
+ case "update-requirement": {
+ const realId = idMapping[op.requirementId] ?? op.requirementId;
+ assertExists(model.requirements, realId, "requirement");
+ return {
+ ...model,
+ requirements: model.requirements.map(r => (r.id === realId ? { ...r, ...op.patch } : r)),
+ };
+ }
+ case "remove-requirement": {
+ const realId = idMapping[op.requirementId] ?? op.requirementId;
+ assertExists(model.requirements, realId, "requirement");
+ return { ...model, requirements: model.requirements.filter(r => r.id !== realId) };
+ }
+ case "add-relation": {
+ const realId = idMapping[op.requirementId] ?? op.requirementId;
+ assertExists(model.requirements, realId, "requirement");
+ return {
+ ...model,
+ requirements: model.requirements.map(r =>
+ r.id === realId ? { ...r, relations: [...r.relations, op.relation] } : r
+ ),
+ };
+ }
+ case "remove-relation": {
+ const realId = idMapping[op.requirementId] ?? op.requirementId;
+ assertExists(model.requirements, realId, "requirement");
+ return {
+ ...model,
+ requirements: model.requirements.map(r =>
+ r.id === realId
+ ? { ...r, relations: r.relations.filter((_, idx) => idx !== op.relationIndex) }
+ : r
+ ),
+ };
+ }
+ }
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────
+
+function cloneModel(model: SysMLModel): SysMLModel {
+ // Shallow-clone collections; nested objects are immutable per our op contract.
+ return {
+ systemOfInterestId: model.systemOfInterestId,
+ blocks: model.blocks.map(b => ({ ...b, properties: [...b.properties], stereotypes: [...b.stereotypes] })),
+ associations: model.associations.map(a => ({ ...a })),
+ constraints: model.constraints.map(c => ({ ...c, appliesTo: [...c.appliesTo] })),
+ requirements: model.requirements.map(r => ({ ...r, relations: [...r.relations] })),
+ };
+}
+
+/**
+ * Decide what id to use for a newly-added element.
+ * - If the requested id is unused, it sticks. (Most common path.)
+ * - Otherwise, append `_2`, `_3`, … until free, and record the mapping from
+ * the requested id to the assigned id so subsequent ops in the same batch
+ * can reference the new element by its tempId.
+ */
+function resolveAddId(
+ desiredId: string,
+ tempId: string | undefined,
+ idMapping: Record,
+ existing: string[]
+): string {
+ const taken = new Set(existing);
+ if (!taken.has(desiredId)) {
+ if (tempId) idMapping[tempId] = desiredId;
+ return desiredId;
+ }
+ let suffix = 2;
+ while (taken.has(`${desiredId}_${suffix}`)) suffix++;
+ const final = `${desiredId}_${suffix}`;
+ if (tempId) idMapping[tempId] = final;
+ idMapping[desiredId] = final;
+ return final;
+}
+
+function assertExists(items: T[], id: string, what: string): void {
+ if (!items.find(i => i.id === id)) throw missing(what, id);
+}
+
+function missing(what: string, id: string): Error {
+ return new Error(`${what} "${id}" not found`);
+}
+
+function classifyError(err: unknown): ApplyError["code"] {
+ const msg = (err as Error).message ?? "";
+ if (msg.includes("not found")) return "MISSING_ELEMENT";
+ if (msg.includes("duplicate")) return "DUPLICATE_ID";
+ return "INVALID_PATCH";
+}
diff --git a/apps/web/lib/sync/ops.ts b/apps/web/lib/sync/ops.ts
new file mode 100644
index 0000000..37747e8
--- /dev/null
+++ b/apps/web/lib/sync/ops.ts
@@ -0,0 +1,235 @@
+// The canonical ModelOp alphabet — every mutation to a SysMLModel is
+// expressible as one or more of these. See docs/sync.md §3 for the design.
+//
+// `tempId` lets clients optimistically create elements with a client-generated
+// id; applyOps may rewrite to a canonical id and return the mapping. (M5
+// in-memory: tempId IS the canonical id; the rewrite seam is here for the
+// later DB / SSE iteration.)
+
+import type {
+ Block,
+ BlockKind,
+ Association,
+ AssociationKind,
+ Constraint,
+ Property,
+ PropertyType,
+ Multiplicity,
+ Requirement,
+ RequirementRelation,
+} from "../sysml/model";
+
+// ─── Block ops ───────────────────────────────────────────────────────────
+
+export interface AddBlockOp {
+ kind: "add-block";
+ block: Block;
+ tempId?: string;
+}
+
+export interface UpdateBlockOp {
+ kind: "update-block";
+ blockId: string;
+ patch: Partial>;
+}
+
+export interface RemoveBlockOp {
+ kind: "remove-block";
+ blockId: string;
+}
+
+// ─── Property ops ────────────────────────────────────────────────────────
+
+export interface AddPropertyOp {
+ kind: "add-property";
+ blockId: string;
+ property: Property;
+ tempId?: string;
+}
+
+export interface UpdatePropertyOp {
+ kind: "update-property";
+ blockId: string;
+ propertyId: string;
+ patch: Partial>;
+}
+
+export interface RemovePropertyOp {
+ kind: "remove-property";
+ blockId: string;
+ propertyId: string;
+}
+
+// ─── Association ops ─────────────────────────────────────────────────────
+
+export interface AddAssociationOp {
+ kind: "add-association";
+ association: Association;
+ tempId?: string;
+}
+
+export interface UpdateAssociationOp {
+ kind: "update-association";
+ associationId: string;
+ patch: Partial>;
+}
+
+export interface RemoveAssociationOp {
+ kind: "remove-association";
+ associationId: string;
+}
+
+// ─── Constraint ops ──────────────────────────────────────────────────────
+
+export interface AddConstraintOp {
+ kind: "add-constraint";
+ constraint: Constraint;
+ tempId?: string;
+}
+
+export interface UpdateConstraintOp {
+ kind: "update-constraint";
+ constraintId: string;
+ patch: Partial>;
+}
+
+export interface RemoveConstraintOp {
+ kind: "remove-constraint";
+ constraintId: string;
+}
+
+// ─── Requirement ops ─────────────────────────────────────────────────────
+
+export interface AddRequirementOp {
+ kind: "add-requirement";
+ requirement: Requirement;
+ tempId?: string;
+}
+
+export interface UpdateRequirementOp {
+ kind: "update-requirement";
+ requirementId: string;
+ patch: Partial>;
+}
+
+export interface RemoveRequirementOp {
+ kind: "remove-requirement";
+ requirementId: string;
+}
+
+export interface AddRelationOp {
+ kind: "add-relation";
+ requirementId: string;
+ relation: RequirementRelation;
+}
+
+export interface RemoveRelationOp {
+ kind: "remove-relation";
+ requirementId: string;
+ relationIndex: number;
+}
+
+// ─── Union ───────────────────────────────────────────────────────────────
+
+export type ModelOp =
+ | AddBlockOp
+ | UpdateBlockOp
+ | RemoveBlockOp
+ | AddPropertyOp
+ | UpdatePropertyOp
+ | RemovePropertyOp
+ | AddAssociationOp
+ | UpdateAssociationOp
+ | RemoveAssociationOp
+ | AddConstraintOp
+ | UpdateConstraintOp
+ | RemoveConstraintOp
+ | AddRequirementOp
+ | UpdateRequirementOp
+ | RemoveRequirementOp
+ | AddRelationOp
+ | RemoveRelationOp;
+
+// ─── Op constructors (call sites stay readable) ─────────────────────────
+
+export function addBlock(block: Block, tempId?: string): AddBlockOp {
+ return { kind: "add-block", block, tempId };
+}
+
+export function updateBlock(blockId: string, patch: UpdateBlockOp["patch"]): UpdateBlockOp {
+ return { kind: "update-block", blockId, patch };
+}
+
+export function removeBlock(blockId: string): RemoveBlockOp {
+ return { kind: "remove-block", blockId };
+}
+
+export function addProperty(blockId: string, property: Property, tempId?: string): AddPropertyOp {
+ return { kind: "add-property", blockId, property, tempId };
+}
+
+export function updateProperty(blockId: string, propertyId: string, patch: UpdatePropertyOp["patch"]): UpdatePropertyOp {
+ return { kind: "update-property", blockId, propertyId, patch };
+}
+
+export function removeProperty(blockId: string, propertyId: string): RemovePropertyOp {
+ return { kind: "remove-property", blockId, propertyId };
+}
+
+export function addAssociation(association: Association, tempId?: string): AddAssociationOp {
+ return { kind: "add-association", association, tempId };
+}
+
+export function updateAssociation(associationId: string, patch: UpdateAssociationOp["patch"]): UpdateAssociationOp {
+ return { kind: "update-association", associationId, patch };
+}
+
+export function removeAssociation(associationId: string): RemoveAssociationOp {
+ return { kind: "remove-association", associationId };
+}
+
+export function addConstraint(constraint: Constraint, tempId?: string): AddConstraintOp {
+ return { kind: "add-constraint", constraint, tempId };
+}
+
+export function updateConstraint(constraintId: string, patch: UpdateConstraintOp["patch"]): UpdateConstraintOp {
+ return { kind: "update-constraint", constraintId, patch };
+}
+
+export function removeConstraint(constraintId: string): RemoveConstraintOp {
+ return { kind: "remove-constraint", constraintId };
+}
+
+export function addRequirement(requirement: Requirement, tempId?: string): AddRequirementOp {
+ return { kind: "add-requirement", requirement, tempId };
+}
+
+export function updateRequirement(requirementId: string, patch: UpdateRequirementOp["patch"]): UpdateRequirementOp {
+ return { kind: "update-requirement", requirementId, patch };
+}
+
+export function removeRequirement(requirementId: string): RemoveRequirementOp {
+ return { kind: "remove-requirement", requirementId };
+}
+
+export function addRelation(requirementId: string, relation: RequirementRelation): AddRelationOp {
+ return { kind: "add-relation", requirementId, relation };
+}
+
+export function removeRelation(requirementId: string, relationIndex: number): RemoveRelationOp {
+ return { kind: "remove-relation", requirementId, relationIndex };
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────
+
+let counter = 0;
+/** Generate a temp id. Distinct prefix so applyOps can recognize them. */
+export function tempId(prefix = "tmp"): string {
+ counter++;
+ return `${prefix}_${Date.now().toString(36)}_${counter}`;
+}
+
+/** Quick property factory used by clients producing add-property ops. */
+export function newProperty(name: string, type: PropertyType = { kind: "string" }, multiplicity: Multiplicity = "0..1"): Property {
+ return { id: tempId("p"), name, type, multiplicity };
+}
diff --git a/apps/web/styles/base.css b/apps/web/styles/base.css
index 6ed7db7..b40eaa3 100644
--- a/apps/web/styles/base.css
+++ b/apps/web/styles/base.css
@@ -1043,3 +1043,19 @@ button { font-family: inherit; }
.sysml-node-issue-soft {
border-style: dotted !important;
}
+
+/* ─── Chip inline rename (M5) ─── */
+.chip-rename {
+ font-family: inherit;
+ font-size: 0.86em;
+ color: inherit;
+ background: var(--surface);
+ border: none;
+ border-bottom: 1px solid var(--accent);
+ outline: none;
+ padding: 0 1px;
+ min-width: 50px;
+ font-weight: 500;
+}
+.chip-rename:focus { background: var(--surface-2); }
+.chip.chip-editing { box-shadow: 0 0 0 1.5px var(--accent); }