Skip to content

feat: add ZenUML diagram editor - #109

Merged
donovan-yohan merged 1 commit into
mainfrom
codex/issue-56-zenuml
Aug 12, 2026
Merged

donovan-yohan merged 1 commit into
mainfrom
codex/issue-56-zenuml

Conversation

@donovan-yohan

@donovan-yohan donovan-yohan commented Aug 12, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • add experimental ZenUML as an exact-pinned, locally bundled external Mermaid parser
  • register it through one lazy process-local promise only for ZenUML sources; built-in parsers never wait and no CDN is used
  • add a dedicated strict ZenUML parser/editor for participants, messages, returns, annotators, nested calls, and control blocks, with unfamiliar valid syntax remaining source-only
  • keep Y.Text canonical and preserve the last-valid preview with truthful loading, load-failed, and unavailable states
  • expose the ZenUML starter through the browser and revision-checked MCP creation path

External dependency policy

  • @mermaid-js/mermaid-zenuml is pinned to 0.2.3
  • @zenuml/core is pinned to 3.50.1
  • dependency-path security overrides pin DOMPurify 3.4.13, PostCSS 8.5.26, and NanoID 3.3.17
  • the production audit reports zero advisories on ZenUML dependency paths; 64 unrelated repository baseline advisory entries remain
  • the ZenUML payload is isolated in a 1,721,416-byte raw / 351,820-byte gzip dynamic chunk
  • the ZenUML chunk is absent from the /s/[id] initial route build manifest, preserving lazy isolation for built-in diagrams

Editor and runtime behavior

  • uses a dedicated ZenUML model rather than the Sequence adapter
  • validates core participant, call, message, return, annotator, control-block, and nesting families
  • supports documented caller-to-method nesting and compound } else { / } else if (...) { forms
  • targets nested insertions by stable structural identity, not mutable array indexes
  • preserves source-only editing for unfamiliar valid ZenUML syntax
  • routes visual mutations through Y.Text and the existing undo/collaboration ownership boundary
  • keeps drafts local, preserves the last-valid preview on failures, and distinguishes loading from plugin-unavailable states

Validation

Exact head: db77d96387dbef96886a37263056895aa7fc0867
Exact tree: 5ded38c2b7a6ddb2d5e8f39885c148a2539ac426

  • frozen install, shared build, lint, typecheck, and production build passed
  • full tests: 689 passed (8 shared, 114 server, 567 web)
  • workspace UX: 47 passed, 0 skipped
  • sequence/ZenUML production browser gate passed
  • protected collaboration gate passed
  • adversarial review and focused re-review closed with no unresolved P0/P1 findings

Closes #56

Summary by CodeRabbit

  • New Features
    • Added ZenUML diagram support with live rendering and semantic editing.
    • Edit, add, delete, and reorder participants, messages, and control blocks.
    • Added a ZenUML starter template and plugin entry in the template picker.
    • Advanced ZenUML syntax remains available for source-based editing.
  • Improvements
    • Added loading and failure feedback for ZenUML rendering.
    • Preserved the last valid preview when rendering encounters an error.
    • Improved responsive layout and scrolling for ZenUML editing controls.

@vercel

vercel Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
arielcharts Ready Ready Preview Aug 12, 2026 12:35am

@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

ZenUML is added as an available external Mermaid plugin. The change adds lazy runtime loading, source-safe semantic editing, starter templates, workspace integration, responsive controls, and browser validation.

Changes

ZenUML catalog and starters

Layer / File(s) Summary
Plugin catalog and starter generation
packages/shared/src/mermaid-diagram-catalog.ts, packages/shared/src/starter-templates.ts, packages/shared/src/starter-templates.test.ts
ZenUML is marked as an available plugin with semantic-form metadata and a generated starter template.
Template picker integration
apps/web/src/components/workspace-template-picker.tsx, apps/web/src/components/workspace-template-picker.test.ts
Available external plugin entries become selectable lazy-plugin buttons and participate in template navigation and counts.

ZenUML runtime

Layer / File(s) Summary
Lazy plugin registration
apps/web/package.json, package.json, apps/web/src/lib/zenuml-runtime.ts, apps/web/src/lib/zenuml-runtime.test.ts
The pinned ZenUML plugin loads lazily, registers with Mermaid, exposes loading and failure states, and avoids repeated failed registrations.
Workspace rendering state
apps/web/src/components/session-workspace.tsx, docs/context-map.md
The workspace prepares the runtime before parsing, displays loading state, preserves preview behavior during failures, and routes ZenUML through its external runtime.

Source model and semantic capabilities

Layer / File(s) Summary
Source parser and mutation API
apps/web/src/lib/zenuml-mutations.ts, apps/web/src/lib/zenuml-mutations.test.ts
ZenUML participants, messages, controls, nested blocks, identities, snapshots, validation, CRUD operations, reordering, and source-format preservation are implemented.
Capability adapter
apps/web/src/lib/diagram-capabilities.ts, apps/web/src/lib/diagram-preview.ts, apps/web/src/lib/diagram-capabilities.test.ts
Representable ZenUML sources use a dedicated semantic-form adapter. Advanced sources remain source-only.

Workspace editor

Layer / File(s) Summary
Canvas controls and mutation wiring
apps/web/src/components/diagram-canvas.tsx, apps/web/src/components/session-workspace.tsx, apps/web/src/app/globals.css
The canvas renders ZenUML participant, message, and control forms with draft caching, identity-based keys, mutation callbacks, validation errors, and responsive layout.

Validation

Layer / File(s) Summary
Browser and end-to-end coverage
e2e-sequence-validate.ts, e2e-workspace-ux-validate.ts
Validation covers ZenUML rendering, editing, canonical source updates, undo, advanced-source fallback, narrow layouts, and available plugin starters.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SessionWorkspace
  participant ZenUmlRuntime
  participant Mermaid
  participant DiagramCanvas
  participant ZenUmlMutations

  SessionWorkspace->>ZenUmlRuntime: prepareMermaidRuntimeForSource(source)
  ZenUmlRuntime->>Mermaid: register ZenUML plugin
  Mermaid-->>SessionWorkspace: runtime ready or load-failed
  SessionWorkspace->>DiagramCanvas: pass ZenUML snapshot and callbacks
  DiagramCanvas->>ZenUmlMutations: submit semantic mutation
  ZenUmlMutations-->>SessionWorkspace: return canonical source
  SessionWorkspace->>Mermaid: parse and render updated source
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive The implementation covers the linked editor, runtime, safety, fallback, starter, and validation goals, but resolved dependency pins cannot be verified because pnpm-lock.yaml was excluded. Include pnpm-lock.yaml or equivalent exact-head dependency evidence to verify the installed ZenUML and override versions.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: adding a ZenUML diagram editor.
Out of Scope Changes check ✅ Passed All reviewed changes support ZenUML rendering, editing, starter exposure, validation, documentation, or dependency hardening; no unrelated code is shown.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/issue-56-zenuml

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (12)
e2e-sequence-validate.ts (2)

218-218: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Tighten the participant assertion.

mutated.includes(' Service') matches any line that contains two spaces followed by Service, including a message body. Assert the exact declaration line instead.

💚 Proposed fix
-  if (!mutated.includes('  Service') || !mutated.includes('  Client->API: confirmed') || !mutated.includes('    if(condition) {')) {
+  if (!/^ {2}Service$/mu.test(mutated) || !mutated.includes('  Client->API: confirmed') || !mutated.includes('    if(condition) {')) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e-sequence-validate.ts` at line 218, Update the participant assertion in
the mutated-sequence validation to match the exact Service declaration line
rather than using the broad substring check `mutated.includes('  Service')`;
preserve the existing Client/API and conditional-content assertions.

217-228: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Remove the redundant closeSourceFlyout calls.

readCanonicalSource already closes the flyout. The later calls are safe no-ops, but they add unnecessary control flow.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e-sequence-validate.ts` around lines 217 - 228, Remove the redundant
closeSourceFlyout calls after readCanonicalSource in the mutation and undo
validation flow. Keep readCanonicalSource responsible for closing the flyout,
while preserving the existing canvas undo and canonical-source assertions.
e2e-workspace-ux-validate.ts (3)

5834-5842: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the document-viewport assertion to the external plugin loop.

The PRIMARY_STARTER_TEMPLATES loop above calls assertDocumentMatchesViewport at Line 5830 for every row. The new EXTERNAL_STARTER_TEMPLATES loop omits it. A plugin row with a long label can therefore introduce horizontal document overflow on a 320px viewport without failing the gate.

💚 Proposed fix
     await assertContainedInViewport(page, menu, `${label} ${template.label} plugin chooser containment`);
+    await assertDocumentMatchesViewport(page, `${label} ${template.label} plugin chooser`);
     assert(await renderedCanvasCameraTransform(page, `${label} ${template.label} plugin chooser camera`) === menuCamera,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e-workspace-ux-validate.ts` around lines 5834 - 5842, Add an
assertDocumentMatchesViewport check inside the EXTERNAL_STARTER_TEMPLATES loop,
alongside the existing touch-target, containment, and camera assertions, so
every external plugin row validates document bounds on narrow viewports like the
PRIMARY_STARTER_TEMPLATES loop.

5913-5917: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the plugin row from the catalog instead of hardcoding ZenUML.

Line 5915 hardcodes 'ZenUML · lazy plugin'. Line 5913 already computes the expected count from EXTERNAL_STARTER_TEMPLATES. A second available plugin, or a ZenUML label change, breaks this check silently or leaves the new plugin untested on phones.

♻️ Proposed refactor
-  const availableZenUml = templateMenu.getByTestId('starter-template-create').filter({ hasText: 'ZenUML · lazy plugin' });
-  await availableZenUml.scrollIntoViewIfNeeded();
-  await assertTouchTarget(page, availableZenUml, `${label} ZenUML plugin template`);
+  for (const template of EXTERNAL_STARTER_TEMPLATES) {
+    const pluginRow = templateMenu.getByTestId('starter-template-create').filter({ hasText: `${template.label} · lazy plugin` });
+    await pluginRow.scrollIntoViewIfNeeded();
+    await assertTouchTarget(page, pluginRow, `${label} ${template.label} plugin template`);
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e-workspace-ux-validate.ts` around lines 5913 - 5917, Replace the hardcoded
`ZenUML · lazy plugin` filter in the touch-target check with a plugin starter
row derived from `EXTERNAL_STARTER_TEMPLATES`. Select an available external
template from that catalog, locate its corresponding `starter-template-create`
row, and preserve the existing `scrollIntoViewIfNeeded` and `assertTouchTarget`
validation for the derived plugin.

1343-1351: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse the counted value in the failure message.

Store await enabledItems.count() in a local variable before the assertion. The current code performs the Playwright count twice on every run.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e-workspace-ux-validate.ts` around lines 1343 - 1351, In the enabled-items
assertion near expectedEnabledItems, store the result of await
enabledItems.count() in a local variable and reuse it both for the comparison
and failure message, avoiding duplicate Playwright count calls.
apps/web/src/components/diagram-canvas.tsx (1)

6112-6133: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Scope the form error state per form.

The three add-forms share one error state and one NumericEditorError at Line 6134. A failed participant submission clears any prior message error, and the single banner does not indicate which form produced the message. The per-item forms at Lines 6148-6152 already use their own local error state.

Split error into three states, or render the banner inside each form.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/diagram-canvas.tsx` around lines 6112 - 6133, Scope
semantic submission errors independently to the participant, message, and
control forms instead of sharing one error state and banner. Update each form’s
runSemanticForm call to use its corresponding state, and render its
NumericEditorError within that form so failures identify the originating form
without clearing errors from the others.
apps/web/src/lib/diagram-preview.ts (1)

36-36: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Derive the adapter parameter type instead of repeating the union.

The adapter parameter repeats almost every member of DiagramAdapterId. Each new family requires a manual edit here in addition to diagram-capabilities.ts. Deriving the type removes that second edit and prevents the two lists from drifting.

♻️ Proposed refactor
-export function canUseSemanticFamilyControls(source: string, preview: DiagramPreview | null, adapter: 'architecture' | 'block' | ... | 'zenuml'): boolean {
+type SemanticFamilyAdapterId = Exclude<DiagramAdapterId, 'flowchart' | 'sequence' | 'er' | 'source-only' | 'unavailable-plugin'>;
+
+export function canUseSemanticFamilyControls(source: string, preview: DiagramPreview | null, adapter: SemanticFamilyAdapterId): boolean {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/diagram-preview.ts` at line 36, Update
canUseSemanticFamilyControls to derive the adapter parameter type from the
existing DiagramAdapterId type instead of declaring a duplicate union. Import or
reference DiagramAdapterId directly, preserving the function’s current accepted
adapter values while ensuring future additions require no manual update here.
apps/web/src/lib/zenuml-mutations.ts (4)

34-35: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Collapse the duplicate representability predicates.

isZenUmlDiagramSource and isZenUmlSourceRepresentable have identical bodies. Two names for one predicate invite divergence later, because "is this ZenUML source" and "can the form represent this source" are different questions. Only isZenUmlSourceRepresentable is consumed by diagram-capabilities.ts and diagram-canvas.tsx in this cohort.

Either delete isZenUmlDiagramSource, or give it a distinct definition (header detection only) if detection must stay separate from representability.

♻️ Proposed change if detection must stay separate
-export function isZenUmlDiagramSource(source: string): boolean { return parseZenUml(source) !== null; }
+export function isZenUmlDiagramSource(source: string): boolean {
+  const header = splitLines(source).find((line) => line.text.trim() && !COMMENT.test(line.text.trim()));
+  return Boolean(header && HEADER.test(header.text.trim()));
+}
 export function isZenUmlSourceRepresentable(source: string): boolean { return parseZenUml(source) !== null; }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/zenuml-mutations.ts` around lines 34 - 35, Remove the
duplicate isZenUmlDiagramSource predicate, leaving isZenUmlSourceRepresentable
as the sole parse-based representability check used by diagram-capabilities.ts
and diagram-canvas.tsx. If detection is required elsewhere, redefine
isZenUmlDiagramSource with distinct header-detection behavior rather than
duplicating parseZenUml(source) !== null.

150-154: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the redundant parse in addZenUmlControl.

Line 151 calls requireZenUml(source || 'zenuml') and discards the result. Line 152 parses the same source again. The first call only serves as validation, which the second call already performs. Parsing runs twice on every control insertion.

♻️ Proposed fix
 export function addZenUmlControl(source: string, control: ZenUmlControl, parent?: ZenUmlBlockIdentity): string {
-  requireZenUml(source || 'zenuml'); const value = normalizeControl(control); const head = formatControl(value);
-  const parsed = requireZenUml(source || 'zenuml');
+  const parsed = requireZenUml(source || 'zenuml');
+  const value = normalizeControl(control); const head = formatControl(value);
   return requireValidMutation(insertBlockStatement(source || 'zenuml', parsed, `${head} {${preferredEol(source || 'zenuml')}__CLOSE__`, parent));
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/zenuml-mutations.ts` around lines 150 - 154, Remove the
discarded requireZenUml call at the start of addZenUmlControl; retain the parsed
value assignment and subsequent requireValidMutation flow, since the retained
call already performs validation.

251-253: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The ZenUML kind members are declared four times. ZenUmlParticipantKind and ZenUmlControlKind are type-literal unions, so no runtime constant exists. Every consumer that needs the members at runtime retypes them. Adding a kind requires four coordinated edits, and any missed edit produces a form option that the normalizer rejects.

  • apps/web/src/lib/zenuml-mutations.ts#L251-L253: export ZENUML_PARTICIPANT_KINDS, ZENUML_MESSAGE_KINDS, and ZENUML_CONTROL_KINDS as as const arrays, derive the three type aliases from them with typeof X[number], and make normalizeParticipant, normalizeMessage, and normalizeControl validate against those arrays.
  • apps/web/src/components/diagram-canvas.tsx#L6063-L6064: delete the local ZENUML_PARTICIPANT_KINDS and ZENUML_CONTROL_KINDS declarations and import the exported constants from ../lib/zenuml-mutations.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/zenuml-mutations.ts` around lines 251 - 253, The ZenUML kind
lists are duplicated across the normalizers and UI. In
apps/web/src/lib/zenuml-mutations.ts lines 251-253, export as-const participant,
message, and control kind arrays, derive the corresponding type aliases from
them, and update normalizeParticipant, normalizeMessage, and normalizeControl to
validate against those arrays. In apps/web/src/components/diagram-canvas.tsx
lines 6063-6064, remove the local participant/control arrays and import the
exported constants from ../lib/zenuml-mutations.

21-32: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Static analysis ReDoS hints on these regexes are false positives.

The ast-grep regexp-from-variable warnings at lines 22-26 and line 268 flag new RegExp built from IDENTIFIER. IDENTIFIER is a module-level string literal, not user input, so no untrusted pattern reaches the regex constructor. The OpenGrep command-injection.exec-js findings are also false positives; those calls are RegExp.prototype.exec, not child_process.exec.

One small improvement remains: line 268 compiles a new RegExp on every normalizeIdentifier call. Hoist it to a module constant.

♻️ Proposed fix
+const IDENTIFIER_ONLY = new RegExp(`^${IDENTIFIER}$`, 'u');
-function normalizeIdentifier(value: string, label: string): string { const next = value.trim(); if (!new RegExp(`^${IDENTIFIER}$`, 'u').test(next)) throw new Error(`ZenUML ${label} must be a simple identifier.`); return next; }
+function normalizeIdentifier(value: string, label: string): string { const next = value.trim(); if (!IDENTIFIER_ONLY.test(next)) throw new Error(`ZenUML ${label} must be a simple identifier.`); return next; }

Also applies to: 268-268

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/zenuml-mutations.ts` around lines 21 - 32, Hoist the regular
expression currently constructed inside normalizeIdentifier into a module-level
constant alongside the other regex definitions. Update normalizeIdentifier to
reuse that constant on every call, preserving the existing matching and
replacement behavior.

Source: Linters/SAST tools

apps/web/src/lib/zenuml-mutations.test.ts (1)

107-138: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add reorder coverage for sibling blocks at equal depth.

The reorder tests move messages and controls that live at the top level only. moveZenUmlMessage and moveZenUmlControl select peers by depth alone, so two sibling blocks produce peers with equal depth. No test pins the expected behavior for that case.

Add a case with two sibling if blocks, each containing one message, and assert the intended result. This locks down the behavior flagged in apps/web/src/lib/zenuml-mutations.ts.

💚 Proposed test
+  it('keeps reorder inside the owning block', () => {
+    const source = 'zenuml\n  if(a) {\n    A->B: one\n  }\n  if(b) {\n    A->B: two\n  }';
+    const diagram = getZenUmlDiagramSnapshot(source);
+    expect(moveZenUmlMessage(source, getZenUmlMessageIdentity(diagram.messages[0]!, diagram.messages), 'down')).toBe(source);
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/zenuml-mutations.test.ts` around lines 107 - 138, Add
reorder coverage in the existing ZenUML mutation tests for two sibling if blocks
at the same depth, each containing one message; move one block with
moveZenUmlControl and assert the resulting serialized source reflects the
intended sibling order while preserving each block’s child message. Use
getZenUmlControlIdentity and the existing control creation flow so the test
specifically exercises equal-depth sibling selection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/components/diagram-canvas.tsx`:
- Around line 6089-6093: Update the alias select rendering near the participant
and message draft state to provide a visible fallback option when aliases is
empty. Ensure both sender and recipient ZenUmlAliasSelect controls render an
option for the current fallback alias, such as Client, while preserving the
existing alias options when participants are present.

In `@apps/web/src/lib/diagram-capabilities.ts`:
- Around line 320-324: Update the available-plugin branch in the diagram
capability mapping to use externalFamily.id: preserve the ZenUML adapter and
family only for the zenuml ID, and return source-only for other available plugin
families while retaining the shared diagram metadata. Add a regression test
covering a second available plugin.

In `@apps/web/src/lib/zenuml-mutations.ts`:
- Around line 143-148: Update moveZenUmlMessage and the corresponding
control-reorder function to track each record’s owning parent block, then
restrict peer selection to records with the same owner as the current item in
addition to the existing depth and block filters. Use the owning-block
relationship when determining reorder candidates so swaps cannot cross sibling
branches, while preserving existing validation and no-op behavior.

In `@package.json`:
- Around line 11-14: Move the four dependency overrides from the package.json
overrides configuration into the pnpm-workspace.yaml overrides section,
preserving their package selectors and versions. Remove the package.json
override entries, then regenerate pnpm-lock.yaml with the workspace
configuration and validate that the lockfile reflects all four overrides.

---

Nitpick comments:
In `@apps/web/src/components/diagram-canvas.tsx`:
- Around line 6112-6133: Scope semantic submission errors independently to the
participant, message, and control forms instead of sharing one error state and
banner. Update each form’s runSemanticForm call to use its corresponding state,
and render its NumericEditorError within that form so failures identify the
originating form without clearing errors from the others.

In `@apps/web/src/lib/diagram-preview.ts`:
- Line 36: Update canUseSemanticFamilyControls to derive the adapter parameter
type from the existing DiagramAdapterId type instead of declaring a duplicate
union. Import or reference DiagramAdapterId directly, preserving the function’s
current accepted adapter values while ensuring future additions require no
manual update here.

In `@apps/web/src/lib/zenuml-mutations.test.ts`:
- Around line 107-138: Add reorder coverage in the existing ZenUML mutation
tests for two sibling if blocks at the same depth, each containing one message;
move one block with moveZenUmlControl and assert the resulting serialized source
reflects the intended sibling order while preserving each block’s child message.
Use getZenUmlControlIdentity and the existing control creation flow so the test
specifically exercises equal-depth sibling selection.

In `@apps/web/src/lib/zenuml-mutations.ts`:
- Around line 34-35: Remove the duplicate isZenUmlDiagramSource predicate,
leaving isZenUmlSourceRepresentable as the sole parse-based representability
check used by diagram-capabilities.ts and diagram-canvas.tsx. If detection is
required elsewhere, redefine isZenUmlDiagramSource with distinct
header-detection behavior rather than duplicating parseZenUml(source) !== null.
- Around line 150-154: Remove the discarded requireZenUml call at the start of
addZenUmlControl; retain the parsed value assignment and subsequent
requireValidMutation flow, since the retained call already performs validation.
- Around line 251-253: The ZenUML kind lists are duplicated across the
normalizers and UI. In apps/web/src/lib/zenuml-mutations.ts lines 251-253,
export as-const participant, message, and control kind arrays, derive the
corresponding type aliases from them, and update normalizeParticipant,
normalizeMessage, and normalizeControl to validate against those arrays. In
apps/web/src/components/diagram-canvas.tsx lines 6063-6064, remove the local
participant/control arrays and import the exported constants from
../lib/zenuml-mutations.
- Around line 21-32: Hoist the regular expression currently constructed inside
normalizeIdentifier into a module-level constant alongside the other regex
definitions. Update normalizeIdentifier to reuse that constant on every call,
preserving the existing matching and replacement behavior.

In `@e2e-sequence-validate.ts`:
- Line 218: Update the participant assertion in the mutated-sequence validation
to match the exact Service declaration line rather than using the broad
substring check `mutated.includes('  Service')`; preserve the existing
Client/API and conditional-content assertions.
- Around line 217-228: Remove the redundant closeSourceFlyout calls after
readCanonicalSource in the mutation and undo validation flow. Keep
readCanonicalSource responsible for closing the flyout, while preserving the
existing canvas undo and canonical-source assertions.

In `@e2e-workspace-ux-validate.ts`:
- Around line 5834-5842: Add an assertDocumentMatchesViewport check inside the
EXTERNAL_STARTER_TEMPLATES loop, alongside the existing touch-target,
containment, and camera assertions, so every external plugin row validates
document bounds on narrow viewports like the PRIMARY_STARTER_TEMPLATES loop.
- Around line 5913-5917: Replace the hardcoded `ZenUML · lazy plugin` filter in
the touch-target check with a plugin starter row derived from
`EXTERNAL_STARTER_TEMPLATES`. Select an available external template from that
catalog, locate its corresponding `starter-template-create` row, and preserve
the existing `scrollIntoViewIfNeeded` and `assertTouchTarget` validation for the
derived plugin.
- Around line 1343-1351: In the enabled-items assertion near
expectedEnabledItems, store the result of await enabledItems.count() in a local
variable and reuse it both for the comparison and failure message, avoiding
duplicate Playwright count calls.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 05126613-de70-4373-bac3-1d76367ce340

📥 Commits

Reviewing files that changed from the base of the PR and between 66e5af2 and db77d96.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (20)
  • apps/web/package.json
  • apps/web/src/app/globals.css
  • apps/web/src/components/diagram-canvas.tsx
  • apps/web/src/components/session-workspace.tsx
  • apps/web/src/components/workspace-template-picker.test.ts
  • apps/web/src/components/workspace-template-picker.tsx
  • apps/web/src/lib/diagram-capabilities.test.ts
  • apps/web/src/lib/diagram-capabilities.ts
  • apps/web/src/lib/diagram-preview.ts
  • apps/web/src/lib/zenuml-mutations.test.ts
  • apps/web/src/lib/zenuml-mutations.ts
  • apps/web/src/lib/zenuml-runtime.test.ts
  • apps/web/src/lib/zenuml-runtime.ts
  • docs/context-map.md
  • e2e-sequence-validate.ts
  • e2e-workspace-ux-validate.ts
  • package.json
  • packages/shared/src/mermaid-diagram-catalog.ts
  • packages/shared/src/starter-templates.test.ts
  • packages/shared/src/starter-templates.ts

Comment on lines +6089 to +6093
const aliases = diagram.participants.map((item) => item.alias);
const firstAlias = aliases[0] ?? 'Client';
const secondAlias = aliases[1] ?? firstAlias;
const [participant, setParticipant] = useState<ZenUmlParticipant>({ alias: 'Service', kind: 'participant', label: 'Service' });
const [message, setMessage] = useState<ZenUmlMessage>({ assignment: null, from: firstAlias, kind: 'async', text: 'request', to: secondAlias });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle the zero-participant case in the alias selects.

aliases is empty when the diagram declares no participants and has no messages. firstAlias then resolves to 'Client' and secondAlias to 'Client', but ZenUmlAliasSelect renders no <option> elements. The select displays an empty control while the draft holds 'Client'. A user cannot see or change the sender and recipient before submitting.

Render a fallback option when aliases is empty.

🐛 Proposed fix
-function ZenUmlAliasSelect({ aliases, label, onChange, value }: { aliases: string[]; label: string; onChange: (value: string) => void; value: string }) { return <select aria-label={label} onChange={(event) => onChange(event.target.value)} style={HIERARCHY_CONTROL_STYLE} value={value}>{aliases.map((alias) => <option key={alias}>{alias}</option>)}</select>; }
+function ZenUmlAliasSelect({ aliases, label, onChange, value }: { aliases: string[]; label: string; onChange: (value: string) => void; value: string }) { const options = aliases.length ? aliases : [value]; return <select aria-label={label} onChange={(event) => onChange(event.target.value)} style={HIERARCHY_CONTROL_STYLE} value={value}>{options.map((alias) => <option key={alias}>{alias}</option>)}</select>; }

Also applies to: 6120-6122

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/diagram-canvas.tsx` around lines 6089 - 6093, Update
the alias select rendering near the participant and message draft state to
provide a visible fallback option when aliases is empty. Ensure both sender and
recipient ZenUmlAliasSelect controls render an option for the current fallback
alias, such as Client, while preserving the existing alias options when
participants are present.

Comment on lines +320 to +324
if (externalFamily.availability === 'available-plugin') {
return {
adapter: 'zenuml', diagramType, editingMode: 'semantic-form', family: 'zenuml', kind: 'generic', label: externalFamily.label,
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the external Mermaid plugin family record shape.
set -euo pipefail

fd 'mermaid-diagram-catalog.ts' packages/shared/src --exec rg -n -C6 'EXTERNAL_MERMAID_PLUGIN_FAMILIES|availability|getExternalMermaidDiagramFamily' {}

Repository: donovan-yohan/arielcharts

Length of output: 14390


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- capability resolver ---'
sed -n '270,350p' apps/web/src/lib/diagram-capabilities.ts

printf '%s\n' '--- related adapter and external-family references ---'
rg -n -C4 'getDiagramSourceModelAdapter|EXTERNAL_MERMAID_PLUGIN_FAMILIES|available-plugin|ZENUML_ADAPTER|source-only' apps/web/src/lib

printf '%s\n' '--- capability tests ---'
sed -n '100,170p' apps/web/src/lib/diagram-capabilities.test.ts

Repository: donovan-yohan/arielcharts

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

path = Path("apps/web/src/lib/diagram-capabilities.ts")
text = path.read_text()

branch = re.search(
    r"const externalFamily = getExternalMermaidDiagramFamily\(diagramType\);"
    r"(.*?)\n\s*}\n\n\s*return \{\n\s*adapter: 'source-only'",
    text,
    re.S,
)
if not branch:
    raise SystemExit("external-family branch not found")

body = branch.group(1)
print("available-plugin branch uses family id:", "externalFamily.id" in body)
print("available-plugin branch returns ZenUML adapter:", "adapter: 'zenuml'" in body)
print("available-plugin branch returns ZenUML family:", "family: 'zenuml'" in body)

def current_dispatch(family):
    if family["availability"] == "available-plugin":
        return {"adapter": "zenuml", "family": "zenuml"}
    return {"adapter": "unavailable-plugin", "family": "zenuml"}

hypothetical = {"id": "other-plugin", "availability": "available-plugin"}
print("hypothetical available other-plugin dispatch:", current_dispatch(hypothetical))
if current_dispatch(hypothetical) != {"adapter": "source-only", "family": "other-plugin"}:
    print("FAIL: an available non-ZenUML family is routed to ZenUML")
    raise SystemExit(1)
PY

Repository: donovan-yohan/arielcharts

Length of output: 459


Key the available-plugin branch by family ID.

externalFamily.id is available, but every available external family currently maps to the ZenUML adapter and family. Return source-only for available families other than zenuml, and add a regression test for a second available plugin.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/diagram-capabilities.ts` around lines 320 - 324, Update the
available-plugin branch in the diagram capability mapping to use
externalFamily.id: preserve the ZenUML adapter and family only for the zenuml
ID, and return source-only for other available plugin families while retaining
the shared diagram metadata. Add a regression test covering a second available
plugin.

Comment on lines +143 to +148
export function moveZenUmlMessage(source: string, identity: ZenUmlMessageIdentity, direction: 'up' | 'down'): string {
const parsed = requireZenUml(source); const current = resolveMessage(parsed, identity);
const peers = parsed.messages.filter((item) => item.depth === current.depth && !item.line.text.trimEnd().endsWith('{'));
const index = peers.indexOf(current); const other = peers[index + (direction === 'up' ? -1 : 1)];
return other ? requireValidMutation(swapLineText(source, current.line, other.line)) : source;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect ZenUML reorder peer selection and its test coverage.
set -euo pipefail

# Show the move implementations.
rg -n -C4 'export function moveZenUml' apps/web/src/lib/zenuml-mutations.ts

# Show whether any test covers reordering inside sibling blocks.
rg -n -C3 'moveZenUmlMessage|moveZenUmlControl' apps/web/src/lib/zenuml-mutations.test.ts

Repository: donovan-yohan/arielcharts

Length of output: 4675


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- ZenUML mutation implementation ---'
sed -n '1,230p' apps/web/src/lib/zenuml-mutations.ts

printf '%s\n' '--- parser and record definitions ---'
rg -n -C5 'interface (MessageRecord|ControlRecord)|type (MessageRecord|ControlRecord)|depth|parent|compoundFragment|parseZen' apps/web/src/lib/zenuml-mutations.ts apps/web/src/lib

printf '%s\n' '--- relevant tests and fixtures ---'
sed -n '1,190p' apps/web/src/lib/zenuml-mutations.test.ts
rg -n -C5 'if\\(a\\)|if\\(b\\)|nested|depth|moveZenUmlMessage|moveZenUmlControl' apps/web/src/lib apps/web

Repository: donovan-yohan/arielcharts

Length of output: 50382


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- parser implementation ---'
sed -n '175,240p' apps/web/src/lib/zenuml-mutations.ts

printf '%s\n' '--- reorder test section ---'
sed -n '85,155p' apps/web/src/lib/zenuml-mutations.test.ts

printf '%s\n' '--- swap helpers ---'
rg -n -C8 'function (swapLineText|swapRanges)|const (CONTROL|SYNC|ASYNC|CLOSE_CONTROL)' apps/web/src/lib/zenuml-mutations.ts

Repository: donovan-yohan/arielcharts

Length of output: 13354


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import re

source = """zenuml
  if(a) {
    A->B: one
  }
  if(b) {
    A->B: two
  }
"""

control = re.compile(r"^(?:(if|while|for|foreach|forEach|loop|catch)\s*(?:\(([^{}\r\n]*)\))?|(?:else\s+if)\s*\(([^{}\r\n]*)\)|(else|opt|par|try|finally))\s*\{\s*$")
async_message = re.compile(r"^([A-Za-z_$][A-Za-z0-9_$.-]*)\s*->\s*([A-Za-z_$][A-Za-z0-9_$.-]*)\s*:\s*(.+?)\s*$")
close = re.compile(r"^\}\s*$")

stack_depth = 0
messages = []
controls = []
lines = source.splitlines(keepends=True)

for index, raw in enumerate(lines):
    text = raw.strip()
    if not text or text == "zenuml":
        continue
    if close.fullmatch(text):
        stack_depth -= 1
        continue
    if async_message.fullmatch(text):
        messages.append((index, stack_depth, raw))
        continue
    if control.fullmatch(text):
        controls.append((index, stack_depth, raw))
        stack_depth += 1
        continue
    raise AssertionError(f"unparsed line: {text!r}")

assert [(depth, raw.strip()) for _, depth, raw in messages] == [
    (1, "A->B: one"),
    (1, "A->B: two"),
]
assert len({depth for _, depth, _ in controls}) == 1

# Model moveZenUmlMessage: same-depth, non-opening message peers.
message_peer_indexes = [i for i, _, _ in messages]
a, b = message_peer_indexes
mutated = list(lines)
mutated[a], mutated[b] = mutated[b], mutated[a]
message_result = "".join(mutated)

assert "if(a) {\n    A->B: two\n" in message_result
assert "if(b) {\n    A->B: one\n" in message_result

control_source = """zenuml
  if(a) {
    if(x) {
      A->B: one
    }
  }
  if(b) {
    if(y) {
      A->B: two
    }
  }
"""

# Model moveZenUmlControl: same-depth controls in separate parent blocks.
control_lines = control_source.splitlines(keepends=True)
control_records = []
depth = 0
for index, raw in enumerate(control_lines):
    text = raw.strip()
    if not text or text == "zenuml":
        continue
    if close.fullmatch(text):
        depth -= 1
        continue
    if control.fullmatch(text):
        control_records.append((index, depth))
        depth += 1
        continue
    if async_message.fullmatch(text):
        continue
    raise AssertionError(f"unparsed line: {text!r}")

assert control_records == [(1, 0), (2, 1), (6, 0), (7, 1)]
inner = [record for record in control_records if record[1] == 1]
assert len(inner) == 2

# Compute each same-depth control's full range and model swapRanges.
def matching_close(start):
    level = 0
    for index in range(start, len(control_lines)):
        text = control_lines[index].strip()
        if control.fullmatch(text):
            level += 1
        elif close.fullmatch(text):
            level -= 1
            if level == 0:
                return index
    raise AssertionError("missing close")

left_start, right_start = inner[0][0], inner[1][0]
left_end, right_end = matching_close(left_start), matching_close(right_start)
swapped = (
    control_lines[:left_start]
    + control_lines[right_start:right_end + 1]
    + control_lines[left_end + 1:right_start]
    + control_lines[left_start:left_end + 1]
    + control_lines[right_end + 1:]
)
control_result = "".join(swapped)

assert "if(a) {\n    if(y) {" in control_result
assert "if(b) {\n    if(x) {" in control_result
print("same-depth records have different owners; both reorder models move content across branches")
PY

Repository: donovan-yohan/arielcharts

Length of output: 254


Restrict ZenUML reorder peers to the same parent block.

depth does not identify a parent. Same-depth messages and controls in sibling branches are treated as peers, so both reorder functions can move content across branches while requireValidMutation accepts the result. Store each record’s owning block and filter peers by that owner.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/zenuml-mutations.ts` around lines 143 - 148, Update
moveZenUmlMessage and the corresponding control-reorder function to track each
record’s owning parent block, then restrict peer selection to records with the
same owner as the current item in addition to the existing depth and block
filters. Use the owning-block relationship when determining reorder candidates
so swaps cannot cross sibling branches, while preserving existing validation and
no-op behavior.

Comment thread package.json
Comment on lines +11 to +14
"@zenuml/core": "3.50.1",
"@zenuml/core>dompurify": "3.4.13",
"postcss>nanoid": "3.3.17",
"tailwindcss>postcss": "8.5.26"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

pnpm view dompurify@3.4.13 version
pnpm view nanoid@3.3.17 version
pnpm view dompurify@3.4.12 version
pnpm view nanoid@3.3.8 version

Repository: donovan-yohan/arielcharts

Length of output: 3498


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- tracked package/config files ---'
git ls-files | rg '(^|/)(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|\.npmrc|\.pnpmfile\.cjs|packageManager|corepack)' || true

printf '%s\n' '--- package.json ---'
cat -n package.json

printf '%s\n' '--- lockfile header and override-related entries ---'
if [ -f pnpm-lock.yaml ]; then
  sed -n '1,80p' pnpm-lock.yaml
  rg -n -C 3 'dompurify|nanoid|overrides|`@zenuml/core`|tailwindcss|postcss' pnpm-lock.yaml || true
fi

printf '%s\n' '--- package-manager configuration ---'
for f in .npmrc pnpm-workspace.yaml .pnpmfile.cjs; do
  if [ -f "$f" ]; then
    printf '%s\n' "--- $f ---"
    cat -n "$f"
  fi
done

Repository: donovan-yohan/arielcharts

Length of output: 14043


🏁 Script executed:

#!/usr/bin/env bash
set -u

printf '%s\n' '--- available package-manager versions ---'
pnpm --version || true
corepack pnpm --version 2>/dev/null || true

printf '%s\n' '--- pnpm config/help references ---'
pnpm config list 2>/dev/null | rg -n -i 'override|workspace|config' || true
pnpm help overrides 2>/dev/null | sed -n '1,100p' || true

Repository: donovan-yohan/arielcharts

Length of output: 855


🌐 Web query:

pnpm 10.15.0 package.json pnpm.overrides supported configuration pnpm-workspace.yaml

💡 Result:

In pnpm version 10.15.0, the overrides configuration is supported and must be defined in the pnpm-workspace.yaml file [1][2]. Key details regarding this configuration: - Location: The overrides field must be placed at the root of the workspace in the pnpm-workspace.yaml file [3][1]. - Compatibility: While older versions of pnpm allowed pnpm settings in package.json, this changed in pnpm v11 [4]. In pnpm 10.x, users are encouraged to define overrides in pnpm-workspace.yaml [1][5]. - Functionality: The overrides field allows you to force specific dependency versions, backport fixes, replace dependencies with forks, or remove unused dependencies [3][2]. - Advanced Usage: You can use catalog references [3], target specific sub-dependencies using the > syntax [3][2], or remove dependencies entirely by setting the value to "-" [3][1]. Note: If you encounter issues with overrides being ignored, ensure they are correctly defined in pnpm-workspace.yaml rather than the legacy pnpm field in package.json, as the behavior for configuration locations was updated in newer major versions of pnpm [6][4].

Citations:


Move the overrides to pnpm-workspace.yaml.

pnpm 10.15.0 ignores pnpm.overrides in package.json. Move all four overrides to the workspace configuration, then regenerate and validate pnpm-lock.yaml.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` around lines 11 - 14, Move the four dependency overrides from
the package.json overrides configuration into the pnpm-workspace.yaml overrides
section, preserving their package selectors and versions. Remove the
package.json override entries, then regenerate pnpm-lock.yaml with the workspace
configuration and validate that the lockfile reflects all four overrides.

@donovan-yohan
donovan-yohan merged commit 63a92ae into main Aug 12, 2026
7 checks passed
@donovan-yohan
donovan-yohan deleted the codex/issue-56-zenuml branch August 12, 2026 00:55

This branch was successfully deployed

1 active deployment
Preview — db77d963 Deployed Aug 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add optional external ZenUML rendering and semantic editing

1 participant