sad-mcp 2.1.1 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ export interface ValidationIssue {
2
+ path: string;
3
+ code: string;
4
+ message: string;
5
+ }
6
+ export type ValidateResult = {
7
+ ok: true;
8
+ warnings: ValidationIssue[];
9
+ } | {
10
+ ok: false;
11
+ issues: ValidationIssue[];
12
+ warnings: ValidationIssue[];
13
+ };
14
+ export declare function formatIssues(issues: ValidationIssue[]): string;
15
+ export declare function parseAndValidate(jsonText: string): ValidateResult;
@@ -0,0 +1,218 @@
1
+ // Validator for use-case diagramModel JSON (kind: 'use-case').
2
+ // Called by uml_usecase_validate_model MCP tool in tools.ts.
3
+ // Returns all errors and warnings at once so an LLM can self-correct in one pass.
4
+ export function formatIssues(issues) {
5
+ if (issues.length === 0)
6
+ return "(none)";
7
+ return issues.map(i => `- [${i.code}] ${i.path}: ${i.message}`).join("\n");
8
+ }
9
+ // ── Helpers ───────────────────────────────────────────────────────────────
10
+ function hasHebrew(s) {
11
+ return /[\u0590-\u05FF\uFB1D-\uFB4F]/.test(s);
12
+ }
13
+ const SEND_ONLY_PREFIXES = [
14
+ /^שליחת /i, /^הודעה ל/i, /^הודעת /i, /^משלוח /i,
15
+ /^Send /i, /^Print /i, /^Notify /i,
16
+ ];
17
+ const PHYSICAL_VERB_PREFIXES = [
18
+ /^ביצוע /i, /^הכנת /i, /^ספירת /i, /^מסירת /i,
19
+ /^תשלום ב/i, /^Perform /i, /^Execute /i,
20
+ ];
21
+ const UNSAFE_WF = [
22
+ /<script\b/i, /\son\w+\s*=/i, /<iframe\b/i,
23
+ /src\s*=\s*["']https?:/i, /href\s*=\s*["']https?:/i,
24
+ /@import\b/i, /<style\b/i,
25
+ ];
26
+ function wireframeSafe(html) {
27
+ return !UNSAFE_WF.some(rx => rx.test(html));
28
+ }
29
+ // ── Main validator ────────────────────────────────────────────────────────
30
+ export function parseAndValidate(jsonText) {
31
+ let raw;
32
+ try {
33
+ raw = JSON.parse(jsonText);
34
+ }
35
+ catch (err) {
36
+ const msg = err instanceof Error ? err.message : String(err);
37
+ return { ok: false, warnings: [], issues: [{ path: "(root)", code: "invalid-json", message: `JSON parse failed: ${msg}` }] };
38
+ }
39
+ const errors = [];
40
+ const warnings = [];
41
+ const err = (path, code, message) => errors.push({ path, code, message });
42
+ const warn = (path, code, message) => warnings.push({ path, code, message });
43
+ const m = raw;
44
+ if (!m || typeof m !== "object") {
45
+ err("(root)", "not-object", "model must be an object");
46
+ return { ok: false, issues: errors, warnings };
47
+ }
48
+ if (m["kind"] !== "use-case")
49
+ err("kind", "wrong-kind", `expected "use-case", got "${m["kind"]}"`);
50
+ const parts = Array.isArray(m["parts"]) ? m["parts"] : [];
51
+ if (!Array.isArray(m["parts"]) || parts.length === 0)
52
+ err("parts", "missing-parts", "parts array is required and must be non-empty");
53
+ // Narrative field checks at model level
54
+ for (const [key, val] of [["assumptions", m["assumptions"]], ["openQuestions", m["openQuestions"]]]) {
55
+ if (Array.isArray(val)) {
56
+ val.forEach((item, i) => {
57
+ if (typeof item === "string" && item.length > 0 && !hasHebrew(item)) {
58
+ err(`${key}[${i}]`, "narrative-non-hebrew", `${key}[${i}] contains no Hebrew characters`);
59
+ }
60
+ });
61
+ }
62
+ }
63
+ for (const [pi, part] of parts.entries()) {
64
+ const pp = `parts[${pi}]`;
65
+ const elements = Array.isArray(part["elements"]) ? part["elements"] : [];
66
+ const relationships = Array.isArray(part["relationships"]) ? part["relationships"] : [];
67
+ const actorIds = new Set();
68
+ const ucIds = new Set();
69
+ // Index actors and UCs
70
+ for (const el of elements) {
71
+ if (el["kind"] === "actor" && typeof el["id"] === "string")
72
+ actorIds.add(el["id"]);
73
+ if (el["kind"] === "useCase" && typeof el["id"] === "string")
74
+ ucIds.add(el["id"]);
75
+ }
76
+ // Build relationship indexes
77
+ const includesByTarget = new Map(); // targetUcId → [baseUcId...]
78
+ const assocsByUc = new Map(); // ucId → [actorId...]
79
+ const includeSet = new Set(); // "from:to"
80
+ let totalIncludeExtend = 0;
81
+ for (const rel of relationships) {
82
+ if (rel["kind"] === "include") {
83
+ const from = String(rel["from"] ?? ""), to = String(rel["to"] ?? "");
84
+ const list = includesByTarget.get(to) ?? [];
85
+ list.push(from);
86
+ includesByTarget.set(to, list);
87
+ includeSet.add(`${from}:${to}`);
88
+ totalIncludeExtend++;
89
+ }
90
+ if (rel["kind"] === "extend")
91
+ totalIncludeExtend++;
92
+ if (rel["kind"] === "actorAssoc") {
93
+ const ucId = String(rel["ucId"] ?? ""), actorId = String(rel["actorId"] ?? "");
94
+ const list = assocsByUc.get(ucId) ?? [];
95
+ list.push(actorId);
96
+ assocsByUc.set(ucId, list);
97
+ }
98
+ }
99
+ if (totalIncludeExtend > 2)
100
+ warn(`${pp}.relationships`, "too-many-include-extend", `${totalIncludeExtend} include+extend relationships found — more than 2 is almost always wrong`);
101
+ // Check «include» targets
102
+ for (const [targetId, bases] of includesByTarget) {
103
+ if (bases.length < 2) {
104
+ err(`${pp}.relationships[include→${targetId}]`, "single-incoming-include", `UC "${targetId}" has exactly one incoming «include» (from "${bases[0]}") — fold it into the parent instead`);
105
+ }
106
+ if (assocsByUc.has(targetId)) {
107
+ err(`${pp}.relationships[include→${targetId}]`, "include-target-has-assoc", `UC "${targetId}" is an «include» target but also has direct actor associations — it cannot be both`);
108
+ }
109
+ }
110
+ // Check UC elements
111
+ for (const el of elements) {
112
+ if (el["kind"] !== "useCase")
113
+ continue;
114
+ const id = String(el["id"] ?? "");
115
+ const name = String(el["name"] ?? "");
116
+ const ePath = `${pp}.elements[id=${id}]`;
117
+ // Send-only check
118
+ if (SEND_ONLY_PREFIXES.some(rx => rx.test(name))) {
119
+ err(ePath, "send-only-uc", `UC "${id}" name "${name}" appears to be send-only — fold the send step into the UC that decided to send`);
120
+ }
121
+ // Physical verb check
122
+ if (PHYSICAL_VERB_PREFIXES.some(rx => rx.test(name))) {
123
+ warn(ePath, "physical-verb", `UC "${id}" name "${name}" uses a physical verb — wrap with a system verb (רישום, הזנה, עדכון, סימון…)`);
124
+ }
125
+ // Primary actor
126
+ const primaryActor = el["primaryActor"];
127
+ if (!primaryActor) {
128
+ err(ePath, "missing-primary-actor", `UC "${id}" has no primaryActor`);
129
+ }
130
+ else {
131
+ if (!actorIds.has(primaryActor)) {
132
+ err(ePath, "unknown-primary-actor", `UC "${id}" primaryActor "${primaryActor}" is not in actors list`);
133
+ }
134
+ // Check that an actorAssoc with role=initiator exists for this actor↔UC pair
135
+ const initiatorAssoc = relationships.find(r => r["kind"] === "actorAssoc" && r["ucId"] === id && r["actorId"] === primaryActor && r["role"] === "initiator");
136
+ if (!initiatorAssoc) {
137
+ err(ePath, "primary-not-initiator", `UC "${id}" primaryActor "${primaryActor}" has no actorAssoc with role="initiator"`);
138
+ }
139
+ }
140
+ // Narrative fields — must contain Hebrew
141
+ for (const [key, val] of [["description", el["description"]]]) {
142
+ if (typeof val === "string" && val.length > 0 && !hasHebrew(val)) {
143
+ err(`${ePath}.${key}`, "narrative-non-hebrew", `${key} contains no Hebrew characters`);
144
+ }
145
+ }
146
+ for (const [key, arr] of [["preconditions", el["preconditions"]], ["postconditions", el["postconditions"]]]) {
147
+ if (!Array.isArray(arr) || arr.length === 0) {
148
+ warn(`${ePath}.${key}`, `missing-${key}`, `UC "${id}" has no ${key}`);
149
+ }
150
+ else {
151
+ arr.forEach((item, i) => {
152
+ if (typeof item === "string" && item.length > 0 && !hasHebrew(item)) {
153
+ err(`${ePath}.${key}[${i}]`, "narrative-non-hebrew", `${key}[${i}] contains no Hebrew characters`);
154
+ }
155
+ });
156
+ }
157
+ }
158
+ // User stories
159
+ const userStories = Array.isArray(el["userStories"]) ? el["userStories"] : [];
160
+ if (userStories.length < 2) {
161
+ warn(`${ePath}.userStories`, "few-user-stories", `UC "${id}" has ${userStories.length} user stor${userStories.length === 1 ? "y" : "ies"} — cover every distinct stakeholder goal`);
162
+ }
163
+ // Scenarios / flow steps
164
+ const scenarios = Array.isArray(el["scenarios"]) ? el["scenarios"] : [];
165
+ if (scenarios.length === 0) {
166
+ err(`${ePath}.scenarios`, "missing-scenarios", `UC "${id}" has no scenarios`);
167
+ }
168
+ for (const [si, sce] of scenarios.entries()) {
169
+ const sPath = `${ePath}.scenarios[${si}]`;
170
+ const flow = Array.isArray(sce["flow"]) ? sce["flow"] : [];
171
+ if (flow.length === 0) {
172
+ err(`${sPath}.flow`, "empty-flow", `scenario "${sce["id"]}" in UC "${id}" has no flow steps`);
173
+ }
174
+ const flowLen = flow.length;
175
+ for (const [fi, step] of flow.entries()) {
176
+ const stepPath = `${sPath}.flow[${fi}]`;
177
+ if (step["kind"] === "actor" && typeof step["actor"] === "string" && !actorIds.has(step["actor"])) {
178
+ err(stepPath, "unknown-actor-ref", `flow step references unknown actor id "${step["actor"]}"`);
179
+ }
180
+ if (step["kind"] === "include") {
181
+ const targetUc = String(step["uc"] ?? "");
182
+ if (!ucIds.has(targetUc)) {
183
+ err(stepPath, "unknown-uc-ref", `«include» step references unknown UC id "${targetUc}"`);
184
+ }
185
+ else if (!includeSet.has(`${id}:${targetUc}`)) {
186
+ err(stepPath, "include-step-no-relationship", `«include» step references "${targetUc}" but no include relationship exists from "${id}" to "${targetUc}"`);
187
+ }
188
+ }
189
+ if (typeof step["text"] === "string" && step["text"].length > 0 && !hasHebrew(step["text"])) {
190
+ err(stepPath, "narrative-non-hebrew", "flow step text contains no Hebrew characters");
191
+ }
192
+ }
193
+ const extensions = Array.isArray(sce["extensions"]) ? sce["extensions"] : [];
194
+ for (const [ei, ext] of extensions.entries()) {
195
+ const extPath = `${sPath}.extensions[${ei}]`;
196
+ if (typeof ext["at"] === "number" && (ext["at"] < 1 || ext["at"] > flowLen)) {
197
+ err(extPath, "invalid-extension-at", `extension "at" value ${ext["at"]} is out of range (flow has ${flowLen} steps)`);
198
+ }
199
+ if (typeof ext["text"] === "string" && ext["text"].length > 0 && !hasHebrew(ext["text"])) {
200
+ err(extPath, "narrative-non-hebrew", "extension text contains no Hebrew characters");
201
+ }
202
+ }
203
+ }
204
+ // Wireframe safety (only when present and non-empty)
205
+ if (typeof el["wireframe"] === "string" && el["wireframe"].length > 0) {
206
+ if (!wireframeSafe(el["wireframe"])) {
207
+ err(`${ePath}.wireframe`, "unsafe-wireframe", `wireframe for UC "${id}" contains forbidden patterns (script/on*/iframe/external URL/@import/style)`);
208
+ }
209
+ if (!hasHebrew(el["wireframe"])) {
210
+ err(`${ePath}.wireframe`, "wireframe-non-hebrew", `wireframe for UC "${id}" contains no Hebrew characters`);
211
+ }
212
+ }
213
+ }
214
+ }
215
+ if (errors.length > 0)
216
+ return { ok: false, issues: errors, warnings };
217
+ return { ok: true, warnings };
218
+ }
@@ -0,0 +1,15 @@
1
+ export interface ValidationIssue {
2
+ path: string;
3
+ code: string;
4
+ message: string;
5
+ }
6
+ export type ValidateResult = {
7
+ ok: true;
8
+ warnings: ValidationIssue[];
9
+ } | {
10
+ ok: false;
11
+ issues: ValidationIssue[];
12
+ warnings: ValidationIssue[];
13
+ };
14
+ export declare function formatIssues(issues: ValidationIssue[]): string;
15
+ export declare function parseAndValidate(jsonText: string): ValidateResult;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sad-mcp",
3
- "version": "2.1.1",
3
+ "version": "2.2.1",
4
4
  "description": "MCP server for Software Analysis and Design course materials at BGU",
5
5
  "type": "module",
6
6
  "bin": {
@@ -272,8 +272,14 @@ const diagramLayout = {
272
272
 
273
273
  ### fixLayout implementation
274
274
 
275
+ `fixLayout`, its constants, and all other runtime functions are provided by
276
+ `https://themathbible.com/sad-mcp/uc-diagram-common.js` — **do not inline them**.
277
+ The HTML `<script>` block must contain only the diagram-specific data objects
278
+ (`diagramLayouts`, `actorDescriptions`, `assocDescriptions`, `useCaseDocs`, `diagramModel`).
279
+
280
+ The function signature and constants for reference (implementation is external):
281
+
275
282
  ```javascript
276
- // Named constants — tune these, do not hardcode magic numbers elsewhere.
277
283
  const HEADER_H = 44; // boundary header height
278
284
  const UC_PAD = 30; // min gap from boundary edge to nearest UC edge
279
285
  const UC_GAP = 14; // min gap between UC ellipses in the same column
@@ -439,13 +445,6 @@ function fixLayout(layout) {
439
445
  </div>
440
446
  ```
441
447
 
442
- ```css
443
- .fix-btn { padding: 8px 20px; border: 1px solid #3b82f6; background: #eff6ff;
444
- color: #1e40af; font-size: 12px; font-weight: 600; cursor: pointer;
445
- border-radius: 6px; font-family: 'Noto Sans Hebrew', sans-serif; }
446
- .fix-btn:hover { background: #dbeafe; }
447
- ```
448
-
449
448
  ---
450
449
 
451
450
  ## 7. Splitting Into Multiple Diagrams
@@ -495,7 +494,11 @@ function showDesc(id, type, event) {
495
494
  }
496
495
  ```
497
496
 
498
- ### 8b. Small popup for actors and associations
497
+ All popup and modal functions (`showDesc`, `showPopup`, `hideDesc`, `openUcModal`,
498
+ `closeUcModal`, `pulseActor`, `wireframeSafe`) are provided by
499
+ `uc-diagram-common.js` — **do not inline them**.
500
+
501
+ ### 8b. Data objects for popups (inline in the HTML `<script>` block)
499
502
 
500
503
  ```javascript
501
504
  const actorDescriptions = {
@@ -503,48 +506,16 @@ const actorDescriptions = {
503
506
  // ... one entry per actor
504
507
  };
505
508
  const assocDescriptions = {
506
- // keyed by connection id; name = actor-name ↔ UC-name (auto-composed is fine)
509
+ // keyed by connection id; name = actor-name ↔ UC-name
507
510
  'a1': { name: 'לקוח ↔ פתיחת הזמנה', desc: 'הלקוח יוזם את פתיחת ההזמנה. תפקיד: Initiator.' },
508
511
  // ... one entry per association line you want described
509
512
  };
510
-
511
- function showPopup(id, type, event) {
512
- hideDesc();
513
- const data = type === 'actor' ? actorDescriptions[id]
514
- : type === 'assoc' ? assocDescriptions[id]
515
- : null;
516
- if (!data) return;
517
- const panel = document.createElement('div');
518
- panel.id = 'desc-panel';
519
- panel.innerHTML = `
520
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
521
- <strong style="font-size:12px;color:#1e40af;direction:rtl;">${data.name}</strong>
522
- <span onclick="hideDesc()" style="cursor:pointer;color:#94a3b8;font-size:18px;line-height:1;margin-right:6px;">×</span>
523
- </div>
524
- <div style="font-size:11px;color:#334155;line-height:1.6;direction:rtl;">${data.desc}</div>`;
525
- Object.assign(panel.style, {
526
- position:'fixed', background:'#fff', border:'1.5px solid #2980b9',
527
- borderRadius:'8px', padding:'12px 16px', maxWidth:'300px',
528
- boxShadow:'0 4px 14px rgba(0,0,0,.15)', zIndex:'1000',
529
- left: event.clientX + 'px', top: event.clientY + 'px'
530
- });
531
- document.body.appendChild(panel);
532
- const r = panel.getBoundingClientRect();
533
- if (r.right > window.innerWidth) panel.style.left = (window.innerWidth - r.width - 10) + 'px';
534
- if (r.bottom > window.innerHeight) panel.style.top = (event.clientY - r.height - 10) + 'px';
535
- event.stopPropagation();
536
- }
537
- function hideDesc() { document.getElementById('desc-panel')?.remove(); }
538
- document.addEventListener('click', e => {
539
- if (!e.target.closest('[data-uc-id]') &&
540
- !e.target.closest('[data-actor-id]') &&
541
- !e.target.closest('[data-conn-id]')) hideDesc();
542
- });
543
513
  ```
544
514
 
545
- ### 8a. Full-screen modal for UCs
515
+ ### 8a. useCaseDocs data object (inline in the HTML `<script>` block)
546
516
 
547
- Every UC ellipse's click routes to `openUcModal(id)`, which reads `useCaseDocs[id]` (populated from the §4.4 documentation blocks) and renders a full-screen modal with the complete UC spec. The modal has sections for: header (id + name + primary actor link), description, scenarios (numbered flow with typed steps + extensions), a row with preconditions / postconditions / user stories, and an optional wireframe.
517
+ `openUcModal(id)` reads `useCaseDocs[id]` and renders a full-screen modal with
518
+ the complete UC spec. Populate one entry per UC:
548
519
 
549
520
  ```javascript
550
521
  const useCaseDocs = {
@@ -559,10 +530,10 @@ const useCaseDocs = {
559
530
  scenarios: [
560
531
  { id: 'sc-happy', name: 'מסלול מרכזי',
561
532
  flow: [
562
- { kind: 'actor', actor: 'customer', text: 'בוחר פריט מהקטלוג' },
563
- { kind: 'system', text: 'מוסיף את הפריט לעגלה' },
564
- { kind: 'include', uc: 'uc-authenticate', text: '' },
565
- { kind: 'actor', actor: 'customer', text: 'מאשר את ההזמנה' }
533
+ { kind: 'actor', actor: 'customer', text: 'בוחר פריט מהקטלוג' },
534
+ { kind: 'system', text: 'מוסיף את הפריט לעגלה' },
535
+ { kind: 'include', uc: 'uc-authenticate', text: '' },
536
+ { kind: 'actor', actor: 'customer', text: 'מאשר את ההזמנה' }
566
537
  ],
567
538
  extensions: [
568
539
  { at: 1, text: 'אם הפריט אזל במלאי — מציג הודעה וחוזר לשלב 1.' }
@@ -576,130 +547,6 @@ const useCaseDocs = {
576
547
  }
577
548
  // ... one entry per UC
578
549
  };
579
-
580
- function openUcModal(id) {
581
- const doc = useCaseDocs[id];
582
- if (!doc) return;
583
- const actorName = aid => (actorDescriptions[aid]?.name) || aid;
584
- const ucName = uid => (useCaseDocs[uid]?.name) || uid;
585
- const modal = document.getElementById('uc-modal');
586
- modal.querySelector('.uc-id').textContent = doc.id;
587
- modal.querySelector('.uc-name').textContent = doc.name;
588
- const paLink = modal.querySelector('.uc-primary-link');
589
- paLink.textContent = actorName(doc.primaryActor);
590
- paLink.dataset.actorId = doc.primaryActor;
591
- paLink.onclick = () => { closeUcModal(); pulseActor(doc.primaryActor); };
592
-
593
- modal.querySelector('.uc-description').textContent = doc.description || '';
594
-
595
- // Scenarios
596
- const sc = modal.querySelector('.uc-scenarios');
597
- sc.innerHTML = '';
598
- (doc.scenarios || []).forEach(sce => {
599
- const h = document.createElement('h3'); h.textContent = sce.name || sce.id; sc.appendChild(h);
600
- const ol = document.createElement('ol');
601
- (sce.flow || []).forEach((step, i) => {
602
- const li = document.createElement('li');
603
- if (step.kind === 'actor') {
604
- const strong = document.createElement('strong');
605
- strong.className = 'step-actor';
606
- strong.dataset.actorId = step.actor;
607
- strong.textContent = actorName(step.actor);
608
- strong.onclick = () => { closeUcModal(); pulseActor(step.actor); };
609
- li.appendChild(strong);
610
- li.appendChild(document.createTextNode(': ' + (step.text || '')));
611
- } else if (step.kind === 'system') {
612
- const em = document.createElement('em');
613
- em.className = 'step-system'; em.textContent = 'המערכת';
614
- li.appendChild(em);
615
- li.appendChild(document.createTextNode(': ' + (step.text || '')));
616
- } else if (step.kind === 'include') {
617
- const span = document.createElement('span');
618
- span.className = 'step-include'; span.textContent = '«include» ';
619
- const a = document.createElement('a');
620
- a.className = 'step-uc-ref'; a.textContent = ucName(step.uc);
621
- a.onclick = () => openUcModal(step.uc);
622
- li.appendChild(span); li.appendChild(a);
623
- if (step.text) li.appendChild(document.createTextNode(' — ' + step.text));
624
- } else if (step.kind === 'extension') {
625
- li.className = 'ext-inline';
626
- li.textContent = (step.at ? step.at + 'a. ' : '') + (step.text || '');
627
- }
628
- ol.appendChild(li);
629
- // Inline extensions defined in sce.extensions anchored to this step number:
630
- (sce.extensions || []).filter(e => e.at === i + 1).forEach((ext, j) => {
631
- const sub = document.createElement('li');
632
- sub.className = 'ext-sub';
633
- const letter = String.fromCharCode(97 + j); // a, b, c, ...
634
- sub.textContent = (i + 1) + letter + '. ' + ext.text;
635
- ol.appendChild(sub);
636
- });
637
- });
638
- sc.appendChild(ol);
639
- });
640
-
641
- const fillList = (selector, items) => {
642
- const el = modal.querySelector(selector);
643
- el.innerHTML = '';
644
- (items || []).forEach(s => { const li = document.createElement('li'); li.textContent = s; el.appendChild(li); });
645
- };
646
- fillList('.uc-pre ul', doc.preconditions);
647
- fillList('.uc-post ul', doc.postconditions);
648
- const usList = modal.querySelector('.uc-stories ul');
649
- usList.innerHTML = '';
650
- (doc.userStories || []).forEach(us => {
651
- const li = document.createElement('li');
652
- li.textContent = `As a ${us.role}, I want ${us.want}, so that ${us.so}`;
653
- usList.appendChild(li);
654
- });
655
-
656
- // Wireframe — safety-checked before insertion
657
- const wf = modal.querySelector('.uc-wireframe');
658
- wf.innerHTML = '';
659
- if (doc.wireframe && wireframeSafe(doc.wireframe)) {
660
- wf.innerHTML = doc.wireframe;
661
- wf.hidden = false;
662
- } else {
663
- wf.hidden = true;
664
- if (doc.wireframe) console.warn(`Wireframe for ${id} failed safety check.`);
665
- }
666
-
667
- modal.hidden = false;
668
- document.body.classList.add('uc-modal-open');
669
- }
670
-
671
- function closeUcModal() {
672
- const modal = document.getElementById('uc-modal');
673
- modal.hidden = true;
674
- document.body.classList.remove('uc-modal-open');
675
- }
676
-
677
- // Esc closes the modal.
678
- document.addEventListener('keydown', e => {
679
- if (e.key === 'Escape') closeUcModal();
680
- });
681
-
682
- // Highlight an actor on the diagram for ~2s.
683
- function pulseActor(actorId) {
684
- const g = document.getElementById('actor-group-' + actorId);
685
- if (!g) return;
686
- g.classList.add('actor-pulse');
687
- setTimeout(() => g.classList.remove('actor-pulse'), 2000);
688
- }
689
-
690
- // Wireframe sanity check — see §WF. Rejects scripts, event handlers, external URLs.
691
- function wireframeSafe(html) {
692
- const forbidden = [
693
- /<script\b/i,
694
- /\son\w+\s*=/i,
695
- /<iframe\b/i,
696
- /src\s*=\s*["']https?:/i,
697
- /href\s*=\s*["']https?:/i,
698
- /@import\b/i,
699
- /<style\b/i
700
- ];
701
- return !forbidden.some(rx => rx.test(html));
702
- }
703
550
  ```
704
551
 
705
552
  UC group: `<g id="uc-uc1" data-uc-id="uc1" onclick="showDesc('uc1','uc',event)" style="cursor:pointer">`
@@ -742,46 +589,10 @@ Associations without a meaningful description (e.g., auto-generated from trivial
742
589
 
743
590
  ### Timing labels on cron/time-actor associations (required)
744
591
 
745
- When a connection carries a `timing` field (every connection from a system-time actor MUST have one — see §2), inject the label at the line midpoint **after** `fixLayout` has written the endpoints. The helper below is idempotent: it removes any existing `.timing-label` group before re-rendering, so calling it again after another `fixLayout` pass is safe.
746
-
747
- ```javascript
748
- function renderTimingLabels(layout) {
749
- const svg = document.getElementById(layout.svgId);
750
- if (!svg) return;
751
- svg.querySelectorAll('.timing-label').forEach(el => el.remove());
752
- const NS = 'http://www.w3.org/2000/svg';
753
- (layout.connections || []).forEach(conn => {
754
- if (!conn.timing) return;
755
- const line = svg.querySelector(
756
- `[data-conn-from="${conn.from}"][data-conn-to="${conn.to}"]`);
757
- if (!line) return;
758
- const x1 = +line.getAttribute('x1'), y1 = +line.getAttribute('y1');
759
- const x2 = +line.getAttribute('x2'), y2 = +line.getAttribute('y2');
760
- const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
761
- const w = 10 + conn.timing.length * 7, h = 16;
762
- const g = document.createElementNS(NS, 'g');
763
- g.setAttribute('class', 'timing-label');
764
- const rect = document.createElementNS(NS, 'rect');
765
- rect.setAttribute('x', mx - w / 2); rect.setAttribute('y', my - h / 2);
766
- rect.setAttribute('width', w); rect.setAttribute('height', h);
767
- rect.setAttribute('fill', 'white'); rect.setAttribute('stroke', '#334155');
768
- rect.setAttribute('stroke-width', '0.8'); rect.setAttribute('rx', '3');
769
- const text = document.createElementNS(NS, 'text');
770
- text.setAttribute('x', mx); text.setAttribute('y', my + 4);
771
- text.setAttribute('font-size', '10'); text.setAttribute('text-anchor', 'middle');
772
- text.setAttribute('fill', '#334155');
773
- text.setAttribute('font-family', "'Noto Sans Hebrew', sans-serif");
774
- text.setAttribute('direction', 'rtl');
775
- text.textContent = conn.timing;
776
- g.appendChild(rect); g.appendChild(text);
777
- svg.appendChild(g);
778
- });
779
- }
780
- ```
781
-
782
- Call `renderTimingLabels(layout)` at the end of every `fixLayout` invocation, **and** on `DOMContentLoaded` (after the initial `fixLayout`). Add it inside `showDiagram(index)` too so the label tracks the active tab.
592
+ `renderTimingLabels(layout)` is provided by `uc-diagram-common.js` do not inline it.
593
+ It is called automatically at the end of every `fixLayout` invocation.
783
594
 
784
- The `connections` entry for a timed link looks like:
595
+ Every connection from a system-time actor MUST carry a `timing` field:
785
596
 
786
597
  ```javascript
787
598
  { from: 'cron', to: 'uc-daily-reconcile', type: 'association', timing: 'פעם ביום' }
@@ -789,15 +600,15 @@ The `connections` entry for a timed link looks like:
789
600
 
790
601
  ---
791
602
 
792
- ## §WF. Wireframe Authoring (Mandatory for Human-Initiated UCs)
603
+ ## §WF. Wireframe Authoring (Opt-In)
793
604
 
794
- **A wireframe is required for every UC whose `primaryActor.actorType === 'human'`.** The user sees a screen; a use case without a visual mock of that screen is incomplete documentation. The validator (§12) errors on any human-initiated UC missing a wireframe.
605
+ **Wireframes are generated only when the user explicitly requests them** (e.g., "add wireframes", "include UI mocks"). Do not generate wireframes unless asked they significantly increase generation time.
795
606
 
796
- Wireframe is **optional** (and usually omitted) only for:
607
+ When wireframes are requested, generate them only for UCs whose `primaryActor.actorType === 'human'`. Skip wireframes for:
797
608
  - UCs whose primary actor is a system or cron (`actorType: 'system'`) — no human sees a screen.
798
609
  - Pure include-target UCs (sub-behaviors with no actor association).
799
610
 
800
- For every other UC, emit a wireframe as an inline HTML fragment and attach it to `useCaseDocs[ucId].wireframe`. The modal renderer (§8a) injects the fragment after passing a safety check (see `wireframeSafe()` in §8a).
611
+ When generated, emit a wireframe as an inline HTML fragment and attach it to `useCaseDocs[ucId].wireframe`. The modal renderer (§8a) injects the fragment after passing a safety check (see `wireframeSafe()` in §8a).
801
612
 
802
613
  Claude authors the wireframe using its own UI-generation judgment — there is no DSL. But the skill enforces a consistent look and safe sandbox via a scoped CSS theme and a set of forbidden patterns.
803
614
 
@@ -915,107 +726,9 @@ Assumptions li: #334155, 12px, line-height 1.6, list-style disc inside
915
726
  Fonts: 'Noto Sans Hebrew' + 'IBM Plex Mono' from Google Fonts
916
727
  ```
917
728
 
918
- The assumptions panel CSS must be included in the HTML `<style>` block:
919
-
920
- ```css
921
- .assumptions-panel { display: flex; gap: 24px; max-width: 1000px;
922
- margin: 20px auto; padding: 16px 20px; background: #fff;
923
- border: 1px solid #cbd5e1; border-radius: 8px; direction: rtl; }
924
- .assumptions-col { flex: 1; min-width: 0; }
925
- .assumptions-col h3 { margin: 0 0 6px; font-size: 13px; font-weight: 600;
926
- color: #1e40af; }
927
- .assumptions-col ul { margin: 0; padding-right: 18px; }
928
- .assumptions-col li { color: #334155; font-size: 12px; line-height: 1.6; }
929
- ```
930
-
931
- ### v2 — UC modal styling
932
-
933
- ```css
934
- /* Lock body scroll while the modal is open */
935
- body.uc-modal-open { overflow: hidden; }
936
-
937
- .uc-modal { position: fixed; inset: 0; z-index: 2000; display: flex;
938
- align-items: flex-start; justify-content: center; padding: 40px 20px;
939
- overflow-y: auto; direction: rtl;
940
- font-family: 'Noto Sans Hebrew', sans-serif; }
941
- .uc-modal-backdrop { position: absolute; inset: 0; background: rgba(15, 23, 42, 0.55); }
942
- .uc-modal-body { position: relative; z-index: 1; width: 100%; max-width: 960px;
943
- background: #fff; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,.25);
944
- padding: 28px 32px 32px; color: #1e293b; }
945
- .uc-modal-close { position: absolute; top: 10px; left: 14px; background: none;
946
- border: none; font-size: 24px; color: #64748b; cursor: pointer; }
947
- .uc-modal-body header { border-bottom: 1px solid #e2e8f0; padding-bottom: 12px;
948
- margin-bottom: 18px; }
949
- .uc-modal-body .uc-id { display: inline-block; font-family: 'IBM Plex Mono',monospace;
950
- font-size: 11px; color: #64748b; background: #f1f5f9; padding: 2px 8px;
951
- border-radius: 4px; margin-bottom: 6px; }
952
- .uc-modal-body .uc-name { margin: 0 0 4px; font-size: 20px; color: #1e40af; font-weight: 700; }
953
- .uc-modal-body .uc-primary { font-size: 13px; color: #334155; }
954
- .uc-modal-body .uc-primary-label { color: #64748b; margin-left: 6px; }
955
- .uc-modal-body .uc-primary-link { color: #1e40af; cursor: pointer; font-weight: 600;
956
- text-decoration: underline dotted; }
957
- .uc-description { margin-bottom: 20px; font-size: 14px; line-height: 1.7; color: #334155; }
958
-
959
- .uc-scenarios h3 { font-size: 14px; color: #1e40af; margin: 18px 0 6px;
960
- border-right: 3px solid #3b82f6; padding-right: 10px; }
961
- .uc-scenarios ol { margin: 0 0 14px; padding-right: 22px; }
962
- .uc-scenarios ol li { color: #1e293b; font-size: 13px; line-height: 1.9; }
963
- .step-actor { color: #1e40af; font-weight: 600; cursor: pointer; text-decoration: underline dotted; }
964
- .step-system { color: #475569; font-style: italic; }
965
- .step-include { color: #7c3aed; font-family: 'IBM Plex Mono', monospace; font-size: 11px; }
966
- .step-uc-ref { color: #7c3aed; cursor: pointer; text-decoration: underline; }
967
- .ext-inline, .ext-sub { list-style: none; color: #64748b; font-size: 12px;
968
- padding-right: 24px; line-height: 1.7; }
969
-
970
- .uc-prepostuser { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 18px;
971
- margin: 18px 0 20px; }
972
- .uc-prepostuser > div { background: #f8fafc; border: 1px solid #e2e8f0;
973
- border-radius: 8px; padding: 12px 14px; }
974
- .uc-prepostuser h4 { margin: 0 0 6px; font-size: 12px; color: #1e40af; font-weight: 600; }
975
- .uc-prepostuser ul { margin: 0; padding-right: 18px; font-size: 12px;
976
- color: #334155; line-height: 1.7; }
977
-
978
- .uc-wireframe { border-top: 1px dashed #cbd5e1; padding-top: 16px; margin-top: 6px; }
979
- .uc-wireframe:empty, .uc-wireframe[hidden] { display: none; }
980
-
981
- /* Actor pulse animation used by pulseActor() */
982
- .actor-pulse { animation: actor-pulse 0.6s ease-in-out 3; }
983
- @keyframes actor-pulse {
984
- 0%, 100% { filter: none; }
985
- 50% { filter: drop-shadow(0 0 8px #3b82f6); }
986
- }
987
- ```
729
+ All styles are in `https://themathbible.com/sad-mcp/uc-diagram-common.css` **do not add a `<style>` block**. Do not inline any CSS.
988
730
 
989
- ### v2 Wireframe scoped theme
990
-
991
- ```css
992
- .wf-screen { background: #f8fafc; border: 1px solid #cbd5e1; border-radius: 8px;
993
- padding: 18px 20px; font-family: 'Noto Sans Hebrew', sans-serif;
994
- color: #1e293b; direction: rtl; max-width: 760px; margin: 0 auto; }
995
- .wf-heading { margin: 0 0 14px; font-size: 15px; color: #1e40af; font-weight: 700;
996
- border-bottom: 1px solid #e2e8f0; padding-bottom: 6px; }
997
- .wf-section { margin: 14px 0; }
998
- .wf-row { display: flex; gap: 10px; align-items: center; margin: 6px 0; }
999
- .wf-label { font-size: 12px; color: #475569; min-width: 120px; }
1000
- .wf-input, .wf-textarea, .wf-select { flex: 1; background: #fff;
1001
- border: 1px solid #cbd5e1; border-radius: 4px; padding: 6px 8px;
1002
- font-size: 12px; color: #334155; }
1003
- .wf-textarea { min-height: 60px; }
1004
- .wf-table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 12px; }
1005
- .wf-table th, .wf-table td { border: 1px solid #cbd5e1; padding: 6px 8px;
1006
- text-align: right; }
1007
- .wf-table th { background: #e2e8f0; color: #1e40af; font-weight: 600; }
1008
- .wf-actions { display: flex; gap: 8px; margin-top: 12px; justify-content: flex-start; }
1009
- .wf-button { background: #fff; border: 1px solid #64748b; border-radius: 4px;
1010
- padding: 6px 14px; font-size: 12px; color: #334155; cursor: default; }
1011
- .wf-primary { background: #1e40af; color: #fff; border-color: #1e40af; }
1012
- .wf-image-box { border: 1px dashed #94a3b8; background: #f1f5f9; color: #64748b;
1013
- text-align: center; padding: 24px; border-radius: 4px; font-size: 12px;
1014
- margin: 10px 0; }
1015
- .wf-hint { font-size: 11px; color: #64748b; font-style: italic; margin: 6px 0; }
1016
- .wf-badge { display: inline-block; background: #e0f2fe; color: #0369a1;
1017
- padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: 600; }
1018
- ```
731
+ Available wireframe classes (for §WF): `wf-screen`, `wf-heading`, `wf-row`, `wf-label`, `wf-input`, `wf-textarea`, `wf-select`, `wf-button`, `wf-primary`, `wf-actions`, `wf-table`, `wf-image-box`, `wf-hint`, `wf-section`, `wf-badge`.
1019
732
 
1020
733
  ---
1021
734
 
@@ -1028,7 +741,7 @@ body.uc-modal-open { overflow: hidden; }
1028
741
  <meta charset="UTF-8">
1029
742
  <title>[System] — Use Case Diagram</title>
1030
743
  <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Hebrew:wght@400;600&family=IBM+Plex+Mono:wght@400;700&display=swap" rel="stylesheet">
1031
- <style>/* tab bar, fix-btn, body layout */</style>
744
+ <link href="https://themathbible.com/sad-mcp/uc-diagram-common.css" rel="stylesheet">
1032
745
  </head>
1033
746
  <body>
1034
747
  <!-- Tab bar (only when split) -->
@@ -1040,7 +753,7 @@ body.uc-modal-open { overflow: hidden; }
1040
753
  <!-- Split rationale (only when split) -->
1041
754
  <div class="split-rationale">Split by UC count: N UCs &gt; 8 threshold</div>
1042
755
 
1043
- <!-- Fix Layout button (above each diagram) -->
756
+ <!-- Fix Layout button -->
1044
757
  <div style="text-align:center;margin:8px 0;">
1045
758
  <button onclick="fixLayout(currentLayout())" class="fix-btn">Fix Layout</button>
1046
759
  </div>
@@ -1056,104 +769,44 @@ body.uc-modal-open { overflow: hidden; }
1056
769
  <svg id="diagram-svg-1" viewBox="0 0 1100 900" xmlns="http://www.w3.org/2000/svg">...</svg>
1057
770
  </div>
1058
771
 
1059
- <!-- Assumptions / open-questions panel (render only if at least one non-empty) -->
772
+ <!-- Assumptions / open-questions panel -->
1060
773
  <div id="assumptions-panel" class="assumptions-panel" hidden>
1061
774
  <div class="assumptions-col" id="assumptions-col">
1062
- <h3>הנחות</h3>
1063
- <ul id="assumptions-list"></ul>
775
+ <h3>הנחות</h3><ul id="assumptions-list"></ul>
1064
776
  </div>
1065
777
  <div class="assumptions-col" id="open-questions-col">
1066
- <h3>שאלות פתוחות</h3>
1067
- <ul id="open-questions-list"></ul>
778
+ <h3>שאלות פתוחות</h3><ul id="open-questions-list"></ul>
1068
779
  </div>
1069
780
  </div>
1070
781
 
1071
- <!-- v2 — UC modal (populated by openUcModal; hidden by default) -->
782
+ <!-- UC modal (body is populated by openUcModal in common.js; starts empty) -->
1072
783
  <div id="uc-modal" class="uc-modal" hidden role="dialog" aria-modal="true">
1073
784
  <div class="uc-modal-backdrop" onclick="closeUcModal()"></div>
1074
- <div class="uc-modal-body">
1075
- <button class="uc-modal-close" onclick="closeUcModal()" aria-label="Close">×</button>
1076
- <header>
1077
- <span class="uc-id"></span>
1078
- <h2 class="uc-name"></h2>
1079
- <div class="uc-primary">
1080
- <span class="uc-primary-label">שחקן ראשי:</span>
1081
- <a class="uc-primary-link"></a>
1082
- </div>
1083
- </header>
1084
- <section class="uc-description"></section>
1085
- <section class="uc-scenarios"></section>
1086
- <section class="uc-prepostuser">
1087
- <div class="uc-pre"><h4>תנאים מוקדמים</h4><ul></ul></div>
1088
- <div class="uc-post"><h4>תנאים סופיים</h4><ul></ul></div>
1089
- <div class="uc-stories"><h4>סיפורי משתמש</h4><ul></ul></div>
1090
- </section>
1091
- <section class="uc-wireframe" hidden></section>
1092
- </div>
785
+ <div class="uc-modal-body"></div>
1093
786
  </div>
1094
787
 
1095
788
  <!-- VP export button -->
1096
789
  <button class="vp-export-btn" onclick="exportXMI()">Download .xmi (Visual Paradigm)</button>
1097
790
 
791
+ <!-- DIAGRAM DATA ONLY — all functions are in uc-diagram-common.js.
792
+ Use var (NOT const/let) so these become window properties accessible from the external script.
793
+ Do NOT add activeTab, currentLayout, showDiagram, exportXMI, xmlEscape, downloadFile,
794
+ or any DOMContentLoaded handler here — they all live in uc-diagram-common.js.
795
+ Adding let/const variables with the same names as any variable in the external script
796
+ causes a SyntaxError that silently kills the entire external script. -->
1098
797
  <script>
1099
- const diagramLayouts = [ /* one layout object per tab, per §6 */ ];
1100
- const actorDescriptions = { /* §8b */ };
1101
- const assocDescriptions = { /* §8b */ };
1102
- const useCaseDocs = { /* §8a — full v2 per-UC documentation */ };
1103
- const diagramModel = { /* model-shape.md, kind: 'use-case' */ };
1104
-
1105
- let activeTab = 0;
1106
- function currentLayout() { return diagramLayouts[activeTab]; }
1107
- function showDiagram(index) {
1108
- activeTab = index;
1109
- document.querySelectorAll('.diagram-part').forEach((p, i) => p.hidden = i !== index);
1110
- document.querySelectorAll('.tab').forEach((t, i) => t.classList.toggle('active', i === index));
1111
- fixLayout(diagramLayouts[index]); // idempotent
1112
- }
1113
-
1114
- function fixLayout(layout) { /* §6 */ }
1115
- function showDesc(id, type, event){ /* §8: routes to openUcModal for UCs, showPopup otherwise */ }
1116
- function showPopup(id, type, event){ /* §8b */ }
1117
- function hideDesc() { /* §8b */ }
1118
- function openUcModal(id) { /* §8a */ }
1119
- function closeUcModal() { /* §8a */ }
1120
- function pulseActor(actorId) { /* §8a */ }
1121
- function wireframeSafe(html) { /* §8a / §WF */ }
1122
- function xmiBody(elements, rels) { /* export-buttons.md — v2 emits preconditions/postconditions/ownedBehavior */ }
1123
- function exportXMI() { /* export-buttons.md */ }
1124
-
1125
- function renderAssumptions() {
1126
- const a = (diagramModel && diagramModel.assumptions) || [];
1127
- const q = (diagramModel && diagramModel.openQuestions) || [];
1128
- const panel = document.getElementById('assumptions-panel');
1129
- if (!panel) return;
1130
- if (!a.length && !q.length) { panel.hidden = true; return; }
1131
- panel.hidden = false;
1132
- const ul = id => document.getElementById(id);
1133
- const fill = (listEl, items) => {
1134
- listEl.innerHTML = '';
1135
- items.forEach(s => {
1136
- const li = document.createElement('li');
1137
- li.textContent = s;
1138
- listEl.appendChild(li);
1139
- });
1140
- };
1141
- fill(ul('assumptions-list'), a);
1142
- fill(ul('open-questions-list'), q);
1143
- document.getElementById('assumptions-col').hidden = !a.length;
1144
- document.getElementById('open-questions-col').hidden = !q.length;
1145
- }
1146
-
1147
- document.addEventListener('DOMContentLoaded', () => {
1148
- diagramLayouts.forEach(l => fixLayout(l));
1149
- renderAssumptions();
1150
- });
798
+ var diagramLayouts = [ /* one layout object per tab, per §6 */ ];
799
+ var actorDescriptions = { /* §8b — one entry per actor */ };
800
+ var assocDescriptions = { /* §8b — one entry per association line */ };
801
+ var useCaseDocs = { /* §8a — full per-UC documentation */ };
802
+ var diagramModel = { /* model-shape.md, kind: 'use-case' */ };
1151
803
  </script>
804
+ <script src="https://themathbible.com/sad-mcp/uc-diagram-common.js"></script>
1152
805
  </body>
1153
806
  </html>
1154
807
  ```
1155
808
 
1156
- **Single-tab diagrams still define the full script.** Always emit `diagramLayouts` (one-element array), `activeTab`, `currentLayout()`, **and `showDiagram(index)`**. Only the HTML-level tab bar and the second `.diagram-part` are conditional on splitting. Omitting `showDiagram` is a silent bug: a future edit that adds tabs will have dead onclick handlers with no console error pointing at the cause.
809
+ **Single-tab diagrams still define the full script.** Always emit all five `var` data globals; the tab bar HTML and second `.diagram-part` are the only things conditional on splitting. `showDiagram`, `currentLayout`, `activeTab`, `fixLayout`, `exportXMI` and all other functions live exclusively in `uc-diagram-common.js` never re-declare them inline.
1157
810
 
1158
811
  ---
1159
812
 
@@ -1177,7 +830,7 @@ The validator runs locally (no API cost) and catches what the §4 and CR gates c
1177
830
  - Extensions referencing invalid step numbers → ERROR
1178
831
  - Narrative text missing Hebrew characters (description, preconditions, postconditions, flow/extension text, user stories, assumptions, openQuestions) → ERROR
1179
832
  - Unsafe wireframe HTML (script / on*= / iframe / external URL / @import / inline style) → ERROR
1180
- - Warnings: fewer than 2 user stories on a UC, no preconditions/postconditions, overall >2 include+extend relationships
833
+ - Warnings: fewer than 2 user stories on a UC, no preconditions/postconditions, overall >2 include+extend relationships, wireframe present on a UC when none were requested
1181
834
 
1182
835
  If the response is `✗ Model invalid`, fix every listed error and call the validator again. **Do not write the HTML file until the validator returns `✓ Model valid`.**
1183
836
 
@@ -1195,7 +848,7 @@ Briefly report: actor count, use case count, include/extend count, whether split
1195
848
  - [ ] Every `primaryActor` id exists in `actors[]` AND has an association with `role: 'initiator'` on that UC (aligning §4.1 with §4.4).
1196
849
  - [ ] Every flow step of `kind: 'actor'` references a real actor id; every `kind: 'include'` references a real UC id; every `extension.at` references a valid step number in its scenario's flow.
1197
850
  - [ ] Clicking a UC on the diagram opens the full-screen modal (§8a); Esc / backdrop click closes it. Step actor/UC references are clickable. Actor popup + association popup (§8b) still work for non-UC clicks.
1198
- - [ ] Every UC whose primary actor is human has a wireframe (§WF). System/cron-initiated UCs and pure include-targets may omit.
851
+ - [ ] Wireframes generated only if the user explicitly requested them (§WF). If generated, only for human-initiated UCs; system/cron and include-targets omitted.
1199
852
  - [ ] Any `wireframe` HTML passes `wireframeSafe()` (no `<script>`, no `on*=`, no `<iframe>`, no external URLs, no `@import`, no inline `<style>`); all classes are `wf-*` only; visible text is in Hebrew.
1200
853
  - [ ] Every actor-UC association has a written §4.1 justification block (role, initiates? one-line "why"); every UC has ≥1 association with `initiates = yes`.
1201
854
  - [ ] Every association line carries a transparent click-hitbox (stroke-width ≥12, `data-conn-id`) wired to `showDesc(id,'assoc',event)`; `assocDescriptions` entry exists for each id.