BPMN Modeler Implementation¶
The BPMN Modeler wraps the bpmn-js library in a three-panel React component. This page covers the component structure, the canvas setup decisions, and known rendering issues with their fixes.
Component structure¶
packages/frontend/src/components/BpmnModeler/
├── BpmnModeler.tsx main orchestrator, manages selected process state
├── BpmnCanvas.tsx bpmn-js canvas wrapper (modeler lifecycle, badge overlays, deploy trigger)
├── BpmnProperties.tsx properties panel (right), includes DmnTemplateSelector
├── ProcessList.tsx process list (left), CRUD operations
├── DmnTemplateSelector.tsx DMN/DRD dropdown for BusinessRuleTask linking
├── FormTemplateSelector.tsx Form dropdown for UserTask / StartEvent linking
├── DocumentTemplateSelector.tsx Document template dropdown for UserTask linking ← new in v1.1.0
└── BpmnModeler.css custom styles for canvas rendering fixes and badge overlays
packages/frontend/src/
├── services/
│ ├── bpmnService.ts localStorage CRUD for BpmnProcess records
│ └── formService.ts localStorage CRUD for FormSchema records (shared with FormEditor)
└── utils/
└── bpmnTemplates.ts default BPMN XML templates (new process, example)
Canvas initialisation¶
BpmnCanvas.tsx manages the bpmn-js modeler instance lifecycle:
const modeler = new Modeler({
container: containerRef.current,
moddleExtensions: {
camunda: camundaModdleDescriptor,
},
});
await modeler.importXML(xml);
const canvas = modeler.get('canvas');
canvas.zoom('fit-viewport');
camunda-bpmn-moddle is used instead of an Operaton equivalent because no operaton-bpmn-moddle package exists. Operaton accepts both camunda: and operaton: namespace attributes, so camunda: is safe to use and ensures compatibility with the broader Camunda 7 tooling ecosystem.
Scroll-to-zoom override¶
The bpmn-js default requires Ctrl+Scroll to zoom. This was overridden to plain scroll for consistency with the rest of the application:
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const canvas = modelerRef.current?.get('canvas') as any;
const currentZoom = canvas.zoom();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
canvas.zoom(Math.max(0.2, Math.min(4, currentZoom + delta)));
};
container.addEventListener('wheel', handleWheel, { passive: false });
passive: false is required so preventDefault() is effective on wheel events.
Rendering artifact fix¶
bpmn-js produces black circles and stray lines during drag operations when SVG layer pointer events conflict. The fix in BpmnModeler.css:
.bpmn-container .djs-overlay-container,
.bpmn-container .djs-hit-container,
.bpmn-container .djs-outline-container {
pointer-events: none;
}
.bpmn-container .djs-element {
pointer-events: all;
}
This separates hit detection (on elements) from overlay rendering (no pointer events), eliminating the visual artifacts.
FormTemplateSelector — form linking for UserTask and StartEvent¶
FormTemplateSelector is a React component injected into the bpmn-js properties panel when a UserTask or StartEvent is selected. It reads available forms from FormService and writes camunda:formRef / camunda:formRefBinding to the element's BPMN extension attributes via the bpmn-js modeling API.
Injection¶
The injection follows the same pattern as DmnTemplateSelector. Inside BpmnCanvas.tsx, the selectionChanged listener distinguishes element type and mounts the appropriate selector:
} else if (elementType === 'bpmn:UserTask' || elementType === 'bpmn:StartEvent') {
const selectorContainer = document.createElement('div');
selectorContainer.id = `form-template-custom-${selectedElement.id}`;
propertiesPanel.appendChild(selectorContainer);
const root = ReactDOM.createRoot(selectorContainer);
root.render(
<FormTemplateSelector
element={selectedElement}
modeling={modeling}
selectedFormRef={businessObject.get('camunda:formRef')}
/>
);
}
The cleanupReactRoots() helper unmounts the previous React root whenever the selection changes, preventing stale instances.
Writing attributes¶
When the user selects a form:
modeling.updateProperties(element, {
'camunda:formRef': schemaId, // the schema.id from the FormSchema JSON
'camunda:formRefBinding': 'latest',
'camunda:formKey': undefined, // clears any legacy HTML formKey
});
schemaId is (form.schema as Record<string, unknown>).id — the ID embedded in the form's JSON schema, not the outer FormSchema.id used as the localStorage record key.
Clearing a link¶
Selecting the blank option calls:
modeling.updateProperties(element, {
'camunda:formRef': undefined,
'camunda:formRefBinding': undefined,
});
Form badge overlay¶
When any UserTask or StartEvent has camunda:formRef set, BpmnCanvas.tsx renders a green badge overlay below the element using the bpmn-js overlays service. This is applied on import.done and on every element.changed event.
overlays.add(element.id, 'form-linked', {
position: { bottom: -22, left: leftOffset },
html: `<div class="form-linked-badge" title="${formRef}">📝 ${formRef}</div>`,
});
The badge offset uses leftOffset = Math.round((element.width - badgeWidth) / 2) to centre the badge horizontally beneath the element. A separate CSS class form-linked-badge--start applies smaller font/padding for StartEvent elements, which have a narrower default width.
Styles are defined in BpmnModeler.css:
.form-linked-badge {
background: #16a34a; /* green-600 */
color: white;
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
max-width: 130px;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
pointer-events: none prevents the badge from interfering with element selection on the canvas.
DocumentTemplateSelector — document template linking for UserTask¶
DocumentTemplateSelector is a React component injected into the bpmn-js properties panel when a UserTask is selected (not StartEvent). It reads available document templates from DocumentService and writes ronl:documentRef to the element via the bpmn-js modeling API. It follows the identical injection pattern as FormTemplateSelector.
Injection¶
Inside the selectionChanged listener in BpmnCanvas.tsx, after the FormTemplateSelector is mounted for a UserTask, the document selector is appended immediately below it:
// UserTask only — not StartEvent
if (elementType === 'bpmn:UserTask') {
const docSelectorContainer = document.createElement('div');
docSelectorContainer.id = `document-template-custom-${selectedElement.id}`;
propertiesPanel.appendChild(docSelectorContainer);
const currentDocumentRef = businessObject.get('ronl:documentRef');
const docRoot = ReactDOM.createRoot(docSelectorContainer);
docRoot.render(
);
}
cleanupReactRoots() unmounts all injected React roots (form and document) when the selection changes.
Writing and clearing the attribute¶
Selecting a template:
Selecting the blank option:
Document badge overlay¶
When a UserTask has ronl:documentRef set, BpmnCanvas.tsx renders a purple badge below the element. This is applied in refreshDmnOverlays() alongside the DMN and form badges:
overlays.remove({ type: 'document-linked' });
// ...inside the elementRegistry.forEach loop, after the form badge check:
if (element.type === 'bpmn:UserTask') {
const documentRef = element.businessObject.get('ronl:documentRef');
if (documentRef) {
const badgeWidth = 130;
const leftOffset = Math.round((element.width - badgeWidth) / 2);
overlays.add(element.id, 'document-linked', {
position: { bottom: -36, left: leftOffset }, // below the form badge at -22
html: `📄 ${documentRef}`,
});
}
}
The badge offset is bottom: -36 (vs. bottom: -22 for the form badge), so both badges stack below the element without overlapping.
CSS¶
Defined in BpmnModeler.css:
.document-linked-badge {
background: #7c3aed; /* violet-700 */
color: white;
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
max-width: 130px;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
Badge stacking order¶
| Overlay type | CSS class | Colour | bottom offset |
|---|---|---|---|
dmn-linked |
.dmn-linked-badge |
Blue (#2563eb) |
8 (inside element) |
form-linked |
.form-linked-badge |
Green (#16a34a) |
-22 (below element) |
document-linked |
.document-linked-badge |
Violet (#7c3aed) |
-36 (below form badge) |
refreshDmnOverlays() calls overlays.remove({ type: 'document-linked' }) before re-adding, so stale badges are cleared on every element.changed event.
Deploy modal¶
The deploy modal is triggered by the Deploy button in the canvas toolbar. BpmnCanvas.tsx assembles the resource bundle before opening the modal:
Resource collection¶
// 1. Save current BPMN to get latest XML
const { xml } = await modelerRef.current.saveXML({ format: true });
// 2. Extract subprocess calledElement references (recursive)
const calledElements = extractCalledElements(xml);
// → match against saved BpmnProcess records by process/@id
// 3. Extract all camunda:formRef values from main + subprocess XMLs
const allFormRefs = new Set([
...extractFormRefs(xml),
...subProcessXmls.flatMap(sp => extractFormRefs(sp.xml)),
]);
// 4. Match form refs against FormService.getForms() by schema.id
const forms = allFormRefs → matched FormSchema records
Unmatched form refs (referenced in BPMN but not in localStorage) are passed to the modal as unmatchedForms for display.
API call¶
On Deploy, BpmnCanvas.tsx sends one JSON request to the backend:
await fetch(`${API_BASE_URL}/api/dmns/process/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bpmnXml: xml,
deploymentName: processKey, // from BPMN process/@id
forms, // [{ id, schema }]
documents, // [{ id, template }]
subProcesses: subProcessXmls, // [{ filename, xml }]
boardOwner,
organization: deployOrganization,
}),
});
The request names no Operaton target and no credentials. Since v2026.09.5 the modal no longer offers an Operaton URL, username or password; it names the Operaton the backend deploys to instead.
The call still uses the legacy /api/dmns/... alias, which the backend serves through the /v1 handler with a Deprecation header.
The backend records the bundle in the same request. The response's data carries deploymentId and a bundleRecorded flag, with bundleRecordingError when the write did not land. A deploy Operaton has accepted is a success either way — it cannot be undone — so a failed recording shows as a warning, saying the process will not appear on the dashboard or the public site until it is saved and deployed again. Before v2026.09.5 the browser made that write itself, as a second request that was unawaited, whose failure was swallowed, and which was skipped altogether when local storage held no matching identifier. An error response is read with getProblemDetail(), since errors are RFC 9457 problem details.
Backend endpoint¶
POST /api/dmns/process/deploy (in dmn.routes.ts) delegates to operatonService.deployProcess(). That method builds a multipart/form-data request with each resource appended as a named field matching Camunda Modeler behaviour:
- Main BPMN: field name =
${processKey}.bpmn - Subprocess BPMNs: field name = the subprocess filename
- Forms: field name =
${formId}.form
Processes deploy only to the configured Operaton. deployProcess() always uses the shared client, built from OPERATON_BASE_URL and carrying OPERATON_API_KEY, so Operaton credentials stay on the backend. For older frontends, an operatonUrl equal to the configured one is still accepted; any other answers 400, and operatonUsername and operatonPassword are ignored. All three fields are deprecated. Until v2026.09.5 a URL in the request body made the backend build a new client for that host, with optional Basic Auth — an Operaton target, and credentials for it, chosen by whoever sent the request.
After Operaton accepts the deployment, the route finds the stored process by the id in the BPMN — or creates a minimal row when none exists — and stamps it as deployed, recording the Operaton actually used. Saving a process takes bpmnProcessId from the saved XML, so renaming the process id no longer leaves a stale value that makes the next deploy create a second row.
DmnTemplateSelector¶
DmnTemplateSelector.tsx loads from two sources in parallel when mounted for a BusinessRuleTask:
const loadOptions = async () => {
// Remote: regular DMNs from backend
const response = await fetch(`${API_BASE_URL}/v1/dmns?endpoint=${endpoint}`);
const dmnArray: DmnModel[] = data.data.dmns;
// Local: DRD templates from localStorage
const userTemplates = getUserTemplates(endpoint);
const drdOptions = userTemplates
.filter(t => t.isDrd && t.drdEntryPointId)
.map(t => ({
identifier: t.drdEntryPointId!,
title: `${t.name} (DRD)`,
isDrd: true,
originalChain: t.drdOriginalChain,
}));
setOptions({ drds: drdOptions, dmns: dmnArray });
};
The dropdown renders two <optgroup> elements: "🔗 DRDs (Unified Chains)" and "📋 Single DMNs". Selection auto-populates camunda:decisionRef and suggests a camunda:resultVariable value (derived from the decision title, camelCased).
DmnTemplateSelector pre-selection fix¶
Before v1.0.0, opening the properties panel for a BusinessRuleTask that already had camunda:decisionRef set would show an empty dropdown. The fix reads currentDecisionRef from businessObject.get('camunda:decisionRef') and passes it as selectedDecisionRef to DmnTemplateSelector, which initialises its useState from that prop.
Process persistence¶
bpmnService.ts stores processes as BpmnProcess records in PostgreSQL via the backend, using localStorage as a synchronous read cache. See Asset Storage for the full write-through cache and hydration architecture.
The BpmnProcess type includes three relationship fields added in v1.3.0:
interface BpmnProcess {
// ... existing fields ...
bpmnProcessId?: string; // <process id="..."> from XML
processRole?: 'shell' | 'subprocess' | 'standalone';
calledElement?: string; // parent shell's bpmnProcessId
}
bpmnProcessId is extracted from the XML on save using:
const extractBpmnProcessId = (xml: string): string => {
const match = xml.match(/<(?:bpmn:)?process[^>]+\bid="([^"]+)"/);
return match?.[1] ?? 'unknown';
};
ProcessList.tsx uses calledElement === shell.bpmnProcessId to group subprocesses under their parent shell in the hierarchical view.
Bundle assembly after migration¶
BpmnCanvas.tsx resolves subprocess XMLs for deployment by matching calledElement values from the active BPMN against stored BpmnProcess records. After the PostgreSQL migration the lookup continues to work identically — hydrateFromServer() ensures the local cache reflects the database state on mount, so the in-memory lookup in BpmnService.getProcesses() always has current data.
The backend additionally exposes GET /v1/assets/bpmn/by-bpmn-id/:bpmnProcessId for direct server-side subprocess lookup by BPMN process id.
RoPA linkage — moddleDescriptor and ProcessList¶
ronlModdleDescriptor.json¶
ronlModdleDescriptor.json registers all custom ronl: extensions against bpmn-js so the attributes survive saveXML() serialisation. Without registration, bpmn-js silently strips unknown namespaced attributes on every save.
The descriptor currently declares five type entries:
| Type | Extends | Attribute | Added in |
|---|---|---|---|
DocumentRefMixin |
bpmn:UserTask |
documentRef |
v1.1.0 |
RopaRefMixin |
bpmn:Process |
ropaRef |
v1.4.0 |
DsoActiviteitMixin |
bpmn:Process |
dsoActiviteitUrn |
v1.5.0 |
LanguageMixin |
bpmn:Process |
language |
v1.6.0 |
OrganizationMixin |
bpmn:Process |
organization |
v1.6.0 |
Each entry has the same shape, e.g. for LanguageMixin:
{
"name": "LanguageMixin",
"extends": ["bpmn:Process"],
"properties": [
{ "name": "language", "isAttr": true, "type": "String" }
]
}
When adding a new attribute on bpmn:Process or bpmn:UserTask, append a new mixin entry rather than altering existing ones — each entry is self-contained.
RopaSelector placement¶
RopaSelector.tsx is rendered as a sibling of the scrollable list container inside ProcessList.tsx, not as a child. The JSX structure is:
<div className="w-80 bg-white border-r ..."> ← outer wrapper
<div className="h-14 ..."> ← header
<div className="flex-1 overflow-y-auto ..."> ← scrollable list
</div>
{activeProcess && ( ← RopaSelector — outside scroll container
<div className="border-t ... shrink-0">
<RopaSelector ... />
</div>
)}
</div>
Placing the panel inside the scroll container caused it to scroll away with the list — it must be a sibling to stay pinned.
handleRopaRefChange¶
handleRopaRefChange in BpmnModeler.tsx handles three cases:
ropaRefis a non-empty string andronl:ropaRefalready exists → regex replace the existing valueropaRefis a non-empty string andronl:ropaRefis absent → inject into the<bpmn:process>opening tag before its>or/>ropaRefisundefined→ remove the attribute entirely with a regex that also strips the preceding whitespace
In all cases it first checks for xmlns:ronl= in the XML and injects the namespace declaration on the <definitions> element if absent.
Example process seeding¶
On mount, BpmnModeler.tsx runs a versioned seed effect. For each example defined in EXAMPLE_VERSIONS, if the stored version is lower than the current version the file is re-fetched from public/examples/ and the record is overwritten in localStorage.
Seeded records carry status: 'example', which is what the list badges key on. They are not read-only. Of the ten records the seed effect writes, exactly one — wip_asylum_migration — sets readonly: true; the other nine set readonly: false.
That distinction is load-bearing, because readonly — not status — is what gates the backend write: BpmnService.saveProcess persists to localStorage first and then returns early for a readonly record without ever POSTing to /v1/assets/bpmn. So nine of the ten seeded examples are written to the backend when saved, and hydrateFromServer merges the readonly one back from local storage rather than from the server. A user's edit to a seeded example also survives only until the next version bump: the seed overwrites the stored record whenever EXAMPLE_VERSIONS moves past it.
The current example processes and their roles:
| Seed ID | processRole |
bpmnProcessId |
calledElement |
|---|---|---|---|
example_awb_process |
shell |
AwbShellProcess |
— |
example_tree_felling |
subprocess |
TreeFellingPermitSubProcess |
AwbShellProcess |
example_awb_zorgtoeslag |
shell |
AwbZorgtoeslagProcess |
— |
example_zorgtoeslag_provisional |
subprocess |
ZorgtoeslagProvisionalSubProcess |
AwbZorgtoeslagProcess |
example_zorgtoeslag_final |
subprocess |
ZorgtoeslagFinalSubProcess |
AwbZorgtoeslagProcess |
example_hr_capacity_nl |
standalone |
ManagementCapacityClaimProcess |
— |
example_thuisbatterij_aanvraag |
shell |
ThuisbatterijSubsidieAanvraagProcess |
— |
example_thuisbatterij_decision |
subprocess |
ThuisbatterijSubsidieDecisionSubProcess |
ThuisbatterijSubsidieAanvraagProcess |
wip_asylum_migration |
standalone |
Process_Migratie_en_Asiel |
— |
The Thuisbatterij bundle joined the seed in v2026.09.6. Its files sat in
public/examples/flevoland/ and stopped there — fetchable by URL, invisible in the app, with
no entry in the version registry, no seeding block, no form definitions and no document
template. It was the only bundle in public/examples with no way into the UI, which left
deploying it a file-shuffling exercise rather than the Modeler flow kapvergunning and
zorgtoeslag already have. Both records carry organization: 'flevoland', and the decision
subprocess declares shellId alongside calledElement.
Both Thuisbatterij processes are drawn as a pool with lanes and carry Dutch element names; their element ids are the ones they always had, so nothing that references them by id changes. The main process sits in the pool Subsidie Thuisbatterij Flevoland - Hoofdproces with the lanes Aanvrager, Behandelaar and Systeem. The decision subprocess sits in the pool Thuisbatterijsubsidie - Beoordeling recht en hoogte with only Behandelaar and Systeem — it has no applicant-facing step.
The user task Aanvullende gegevens opvragen (Awb 4:5) (Task_RequestMissingInfo) opens the
form-js form thuisbatterij-aanvullende-gegevens with camunda:formRefBinding="deployment".
That form carries the supplementReceived checkbox the next gateway, Aanvulling ontvangen?,
branches on. The task used to point at an embedded HTML form
(embedded:deployment:awb-missing-info-form.html) that was never part of the bundle, so
nothing could set supplementReceived. The form is seeded as the Form Editor example
example_thuisbatterij_missing_info, and the e2e-fixtures/manifest.json entry for
ThuisbatterijSubsidieAanvraagProcess lists it beside the start and notification forms.
In utils/exampleVersions.ts, example_thuisbatterij_aanvraag and
example_thuisbatterij_decision stand at version 2, so the Modeler replaces copies seeded
before the redraw; example_thuisbatterij_missing_info starts at 1.
A tenanted process cannot see an untenanted DMN
Operaton resolves a business rule task's decisionRef inside the process instance's own
tenant. A process deployed under tenant-id flevoland therefore cannot reach a DMN
deployed without one, and the engine refuses to instantiate it at all — surfacing as a 500
from process start and an unexplained "De aanvraag kon niet worden ingediend" on the ACC
citizen dashboard. Kapvergunning was broken this way; Thuisbatterij had the same defect one
step further in, pinned to a tenant id. decisionRefTenantId="${null}" points them back at
the shared untenanted DMNs.
EXAMPLE_VERSIONS was bumped for example_awb_process, example_tree_felling,
example_awb_zorgtoeslag and example_zorgtoeslag_provisional in the same change —
without the bump the seed skips re-saving, and every existing user keeps the broken copy.
After the seed effect, a separate hydration effect runs BpmnService.hydrateFromServer() to merge any user-authored processes stored in PostgreSQL into the local list.
Pending-until-Save editing model¶
Footer edits — language, organization, ropaRef, dsoActiviteitUrn — accumulate in a draft on BpmnModeler.tsx rather than persisting immediately. The pattern:
type FooterDraft = {
language?: BpmnProcess['language'];
organization?: string;
ropaRef?: string;
dsoActiviteitUrn?: string;
};
const [draft, setDraft] = useState<FooterDraft>({});
const [hasFooterChanges, setHasFooterChanges] = useState(false);
const [hasCanvasChanges, setHasCanvasChanges] = useState(false);
Footer handlers update only the draft:
const handleLanguageChange = (language: string | undefined) => {
if (!activeProcessId) return;
setDraft((d) => ({ ...d, language: language as BpmnProcess['language'] }));
setHasFooterChanges(true);
};
handleSaveProcess flushes the draft into both the in-memory BpmnProcess object and the BPMN XML's ronl: attributes, then resets the draft. Distinguishing "user touched the field" from "user explicitly cleared it" matters — the 'language' in draft idiom preserves the difference between unset and cleared:
The list panel reads from committed processes state so visible grouping doesn't shift while the user is typing in the footer. The footer reads its display value via a readEffective() helper: draft wins when the field has been touched, else the committed BpmnProcess field, else the ronl: attribute extracted from the XML.
Navigation guards (handleCreateProcess, handleLoadProcess, handleCloseProcess) check the combined hasUnsavedChanges = hasCanvasChanges || hasFooterChanges and prompt the user to confirm before discarding. BpmnCanvas exposes its own dirty state via onDirtyChange?: (dirty: boolean) => void so the parent can combine canvas content edits with footer edits into a single guard.
applyRonlAttr helper¶
A single helper consolidates the four duplicated XML-mutation snippets that previously lived in each footer handler. It ensures the xmlns:ronl= namespace is declared on <definitions>, then either rewrites an existing ronl:<attr>= value, injects a new attribute on the <bpmn:process> opening tag, or strips the attribute when the value is undefined:
handleSaveProcess calls it once per draft field present (language, organization, ropaRef, dsoActiviteitUrn).
Shell → subprocess atomic save¶
After saving a shell, handleSaveProcess walks processes for subprocesses where processRole === 'subprocess' and the record links back to this shell. The link is matched on shellId where the record has one, falling back to calledElement === shell.bpmnProcessId for records saved before shellId existed — two shell records can share a bpmnProcessId (an e2e-fixtures copy deliberately keeps a seeded example's production Operaton key), so matching on that string alone would cascade one shell's language and organization onto an unrelated shell's subprocess. For each match, the shell's language and organization are applied to the subprocess XML (via applyRonlAttr) and to the in-memory BpmnProcess fields, then persisted via BpmnService.saveProcess in sequence. The propagation triggers on every shell save when the shell has either field set, regardless of whether the user touched the footer in this session — the architectural rule "shell wins" must hold across editing sessions.
Idempotent: subprocesses already aligned on both fields are skipped (no updatedAt bump, no backend write). Records marked readonly are skipped — which in practice means only wip_asylum_migration, since the seeded example subprocesses are not read-only and do receive the propagation. RoPA and DSO are NOT propagated — each subprocess has its own RoPA record and DSO context.
Testing checklist¶
After changes to any BPMN Modeler component:
- Create new process — process appears in list, empty canvas with start event
- Rename process — double-click list item, save on blur
- Delete process — confirmation dialog, list updates
- Cannot delete example — delete button disabled with tooltip
- Save — process persists across hard refresh
- Export —
.bpmnfile downloads with valid XML - Drag element from palette — element appears on canvas
- Connect elements — arrow tool works
- Select element — properties panel updates
- BusinessRuleTask selected — DMN/DRD dropdown appears and loads
- DRD selected — purple info card shows chain composition
- Single DMN selected — blue info card shows identifier
- Scroll to zoom — wheel event zooms without requiring Ctrl
- Fit to viewport — canvas centres diagram
- No rendering artifacts during drag — no black circles or stray lines