@transitrix/diagrams 1.0.1 → 1.2.0

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.
Files changed (137) hide show
  1. package/dist/activities/__tests__/validate.test.js +32 -1
  2. package/dist/activities/cpm.js +11 -2
  3. package/dist/activities/gantt.js +6 -3
  4. package/dist/activities/types.d.ts +1 -0
  5. package/dist/activities/validate.js +24 -8
  6. package/dist/activity-card/__tests__/resolver.test.js +22 -1
  7. package/dist/activity-card/__tests__/validate.test.js +23 -1
  8. package/dist/activity-card/resolver.js +28 -20
  9. package/dist/activity-card/validate.js +22 -11
  10. package/dist/capability-map/index.d.ts +4 -0
  11. package/dist/capability-map/index.js +2 -0
  12. package/dist/capability-map/layout-tree.d.ts +42 -0
  13. package/dist/capability-map/layout-tree.js +65 -0
  14. package/dist/capability-map/render-capability-tree.d.ts +15 -0
  15. package/dist/capability-map/render-capability-tree.js +108 -0
  16. package/dist/capability-map/types.d.ts +2 -0
  17. package/dist/compliance/__tests__/impact.test.js +198 -0
  18. package/dist/compliance/html.js +1 -7
  19. package/dist/compliance/impact.d.ts +24 -1
  20. package/dist/compliance/impact.js +97 -21
  21. package/dist/factor/__tests__/validate.test.js +3 -3
  22. package/dist/factor/types.d.ts +1 -1
  23. package/dist/factor/validate.js +2 -2
  24. package/dist/fgca/__tests__/chain-table.test.js +2 -2
  25. package/dist/fgca/__tests__/layout.test.js +11 -11
  26. package/dist/fgca/__tests__/preview-layout.test.js +7 -7
  27. package/dist/fgca/__tests__/resolver.test.js +2 -2
  28. package/dist/fgca/__tests__/validate.test.js +2 -2
  29. package/dist/fgca/chain-table.d.ts +1 -1
  30. package/dist/fgca/chain-table.js +4 -4
  31. package/dist/fgca/index.d.ts +2 -2
  32. package/dist/fgca/index.js +1 -1
  33. package/dist/fgca/layout.d.ts +2 -2
  34. package/dist/fgca/layout.js +9 -9
  35. package/dist/fgca/nodeTypes.d.ts +1 -1
  36. package/dist/fgca/nodeTypes.js +2 -2
  37. package/dist/fgca/nodes/FGCAFactorNode.d.ts +3 -3
  38. package/dist/fgca/nodes/FGCAFactorNode.js +5 -5
  39. package/dist/fgca/parse-canonical.js +24 -13
  40. package/dist/fgca/preview-layout.d.ts +1 -1
  41. package/dist/fgca/preview-layout.js +4 -4
  42. package/dist/fgca/resolver.js +3 -1
  43. package/dist/fgca/types.d.ts +13 -11
  44. package/dist/fgca/types.js +2 -2
  45. package/dist/fgca/validate.d.ts +2 -2
  46. package/dist/fgca/validate.js +34 -23
  47. package/dist/process-blueprint/index.d.ts +1 -1
  48. package/dist/process-blueprint/layout.js +64 -37
  49. package/dist/process-blueprint/types.d.ts +14 -0
  50. package/dist/scenarios/index.d.ts +1 -1
  51. package/dist/scenarios/types.d.ts +4 -4
  52. package/dist/theme/index.d.ts +1 -1
  53. package/dist/theme/index.js +1 -1
  54. package/dist/theme/themes.js +28 -3
  55. package/dist/theme/tokens.d.ts +47 -1
  56. package/dist/theme/tokens.js +31 -1
  57. package/dist/webview/__tests__/entry-notations.test.js +2 -0
  58. package/dist/webview/__tests__/render-process.test.d.ts +1 -0
  59. package/dist/webview/__tests__/render-process.test.js +463 -0
  60. package/dist/webview/__tests__/render-util.test.d.ts +1 -0
  61. package/dist/webview/__tests__/render-util.test.js +54 -0
  62. package/dist/webview/entry.d.ts +1 -1
  63. package/dist/webview/entry.js +9 -5
  64. package/dist/webview/render-activities.d.ts +21 -1
  65. package/dist/webview/render-activities.js +91 -56
  66. package/dist/webview/render-activity-card.d.ts +11 -1
  67. package/dist/webview/render-activity-card.js +11 -2
  68. package/dist/webview/render-blocks.d.ts +24 -1
  69. package/dist/webview/render-blocks.js +58 -24
  70. package/dist/webview/render-fgca.d.ts +27 -0
  71. package/dist/webview/render-fgca.js +30 -16
  72. package/dist/webview/render-goals.d.ts +26 -1
  73. package/dist/webview/render-goals.js +30 -20
  74. package/dist/webview/render-process-blueprint.d.ts +11 -1
  75. package/dist/webview/render-process-blueprint.js +118 -53
  76. package/dist/webview/render-process.d.ts +95 -0
  77. package/dist/webview/render-process.js +392 -0
  78. package/dist/webview/render-scenarios.js +2 -2
  79. package/dist/webview/render-util.d.ts +19 -6
  80. package/dist/webview/render-util.js +23 -15
  81. package/package.json +15 -1
  82. package/src/activities/__tests__/validate.test.ts +35 -1
  83. package/src/activities/cpm.ts +14 -2
  84. package/src/activities/gantt.ts +6 -5
  85. package/src/activities/types.ts +1 -0
  86. package/src/activities/validate.ts +24 -8
  87. package/src/activity-card/__tests__/resolver.test.ts +24 -1
  88. package/src/activity-card/__tests__/validate.test.ts +25 -1
  89. package/src/activity-card/resolver.ts +29 -20
  90. package/src/activity-card/validate.ts +20 -11
  91. package/src/capability-map/index.ts +11 -0
  92. package/src/capability-map/layout-tree.ts +114 -0
  93. package/src/capability-map/render-capability-tree.ts +131 -0
  94. package/src/capability-map/types.ts +2 -0
  95. package/src/compliance/__tests__/impact.test.ts +219 -0
  96. package/src/compliance/html.ts +1 -8
  97. package/src/compliance/impact.ts +141 -23
  98. package/src/factor/__tests__/validate.test.ts +3 -3
  99. package/src/factor/types.ts +1 -1
  100. package/src/factor/validate.ts +2 -2
  101. package/src/fgca/__tests__/chain-table.test.ts +2 -2
  102. package/src/fgca/__tests__/layout.test.ts +13 -13
  103. package/src/fgca/__tests__/preview-layout.test.ts +7 -7
  104. package/src/fgca/__tests__/resolver.test.ts +2 -2
  105. package/src/fgca/__tests__/validate.test.ts +2 -2
  106. package/src/fgca/chain-table.ts +5 -5
  107. package/src/fgca/index.ts +3 -3
  108. package/src/fgca/layout.ts +11 -11
  109. package/src/fgca/nodeTypes.ts +3 -3
  110. package/src/fgca/nodes/FGCAFactorNode.tsx +6 -6
  111. package/src/fgca/parse-canonical.ts +26 -15
  112. package/src/fgca/preview-layout.ts +5 -5
  113. package/src/fgca/resolver.ts +3 -1
  114. package/src/fgca/types.ts +15 -13
  115. package/src/fgca/validate.ts +32 -21
  116. package/src/process-blueprint/index.ts +1 -0
  117. package/src/process-blueprint/layout.ts +71 -42
  118. package/src/process-blueprint/types.ts +15 -0
  119. package/src/scenarios/index.ts +2 -2
  120. package/src/scenarios/types.ts +4 -4
  121. package/src/scenarios/validate.ts +3 -3
  122. package/src/theme/index.ts +1 -1
  123. package/src/theme/themes.ts +29 -2
  124. package/src/theme/tokens.ts +33 -1
  125. package/src/webview/__tests__/entry-notations.test.ts +2 -0
  126. package/src/webview/__tests__/render-process.test.ts +538 -0
  127. package/src/webview/__tests__/render-util.test.ts +66 -0
  128. package/src/webview/entry.ts +11 -5
  129. package/src/webview/render-activities.ts +107 -67
  130. package/src/webview/render-activity-card.ts +11 -2
  131. package/src/webview/render-blocks.ts +78 -28
  132. package/src/webview/render-fgca.ts +53 -29
  133. package/src/webview/render-goals.ts +45 -23
  134. package/src/webview/render-process-blueprint.ts +156 -82
  135. package/src/webview/render-process.ts +507 -0
  136. package/src/webview/render-scenarios.ts +3 -3
  137. package/src/webview/render-util.ts +27 -14
@@ -19,20 +19,31 @@ export function validateActivities(input: unknown): ActivityValidationResult {
19
19
 
20
20
  const raw = input as Record<string, unknown>;
21
21
 
22
- // ACT-001: notation must equal "activities"
22
+ // ACT-001: notation must be "action" (canonical) or "activities" (deprecated)
23
23
  if (raw.notation === undefined) {
24
24
  errors.push({ code: 'ACT-001', message: 'notation field is required' });
25
25
  return { valid: false, errors, warnings };
26
26
  }
27
- if (raw.notation !== 'activities') {
28
- errors.push({ code: 'ACT-001', message: `notation must be "activities", got "${String(raw.notation)}"` });
27
+ if (raw.notation !== 'action' && raw.notation !== 'activities') {
28
+ errors.push({ code: 'ACT-001', message: `notation must be "action", got "${String(raw.notation)}"` });
29
29
  return { valid: false, errors, warnings };
30
30
  }
31
+ if (raw.notation === 'activities') {
32
+ warnings.push({ code: 'DEPRECATED_NOTATION', message: 'notation: "activities" is deprecated — migrate to notation: "action"' });
33
+ }
31
34
 
32
- if (!Array.isArray(raw.activities)) {
33
- errors.push({ code: 'SCHEMA_INVALID', message: 'activities must be an array', path: 'activities' });
35
+ // Accept "actions:" (canonical) or "activities:" (deprecated)
36
+ const rawArray = Array.isArray(raw.actions) ? raw.actions : (Array.isArray(raw.activities) ? raw.activities : undefined);
37
+ if (Array.isArray(raw.activities) && !Array.isArray(raw.actions)) {
38
+ warnings.push({ code: 'DEPRECATED_FIELD', message: 'Root key "activities:" is deprecated — rename to "actions:"' });
39
+ }
40
+ if (!rawArray) {
41
+ const canonKey = raw.notation === 'action' ? 'actions' : 'activities';
42
+ errors.push({ code: 'SCHEMA_INVALID', message: `${canonKey} must be an array`, path: canonKey });
34
43
  return { valid: false, errors, warnings };
35
44
  }
45
+ // Normalise onto raw.activities for the rest of this function (avoids touching downstream code)
46
+ if (!Array.isArray(raw.activities)) raw.activities = raw.actions;
36
47
 
37
48
  // ACT-014 / ACT-015: project.calendar validation (run before per-activity loop;
38
49
  // surfaces clearly even when activities also have issues).
@@ -99,6 +110,11 @@ export function validateActivities(input: unknown): ActivityValidationResult {
99
110
  }
100
111
  const act = a as Record<string, unknown>;
101
112
 
113
+ // Normalise action_type → activity_type (canonical rename; old name kept for compat)
114
+ if (act['action_type'] !== undefined && act['activity_type'] === undefined) {
115
+ act['activity_type'] = act['action_type'];
116
+ }
117
+
102
118
  // ACT-010: reject single-value forms
103
119
  if ('goal' in act) {
104
120
  errors.push({ code: 'ACT-010', message: `Use "goals: []" array form instead of "goal:" (activity at index ${i})`, path });
@@ -130,7 +146,7 @@ export function validateActivities(input: unknown): ActivityValidationResult {
130
146
  }
131
147
 
132
148
  // ACT-009: non-negative numeric fields
133
- const numericFields = ['duration', 'labor_cost', 'resources_cost', 'effort', 'score', 'sort'] as const;
149
+ const numericFields = ['duration', 'duration_days', 'labor_cost', 'resources_cost', 'effort', 'score', 'sort'] as const;
134
150
  for (const field of numericFields) {
135
151
  const val = act[field];
136
152
  if (val !== undefined && val !== null) {
@@ -140,8 +156,8 @@ export function validateActivities(input: unknown): ActivityValidationResult {
140
156
  }
141
157
  }
142
158
 
143
- // ACT-011: warn if no duration
144
- if (act.duration === undefined || act.duration === null) {
159
+ // ACT-011: warn if no duration (duration_days is accepted as an alias)
160
+ if ((act.duration === undefined || act.duration === null) && (act.duration_days === undefined || act.duration_days === null)) {
145
161
  warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration — cannot participate in CPM analysis`, path });
146
162
  }
147
163
  }
@@ -41,7 +41,7 @@ const UNRELATED = {
41
41
  name: 'Other',
42
42
  parent: 'ACTIVITY-SOMETHING-ELSE-1',
43
43
  };
44
- const FACTOR = { notation: 'factor', id: 'FACTOR-EU-MDR-1', name: 'EU MDR regulation' };
44
+ const FACTOR = { notation: 'driver', id: 'FACTOR-EU-MDR-1', name: 'EU MDR regulation' };
45
45
  const GOAL = { notation: 'goal', id: 'GOAL-EU-MARKET-1', name: 'Access EU market', factors: ['FACTOR-EU-MDR-1'] };
46
46
  const CHANGE = {
47
47
  notation: 'change',
@@ -233,6 +233,29 @@ describe('resolveActivityCard', () => {
233
233
  expect(r.errors.some((e) => e.code === 'PC-001')).toBe(true);
234
234
  });
235
235
 
236
+ it('PC-001 diagnostic discloses paths searched and actionable hint (#344)', () => {
237
+ const r = resolveActivityCard(CARD, { elements: [FACTOR, GOAL, CHANGE], relations });
238
+ const pc001 = r.errors.find((e) => e.code === 'PC-001');
239
+ expect(pc001).toBeDefined();
240
+ // Must disclose both searched paths (§7 PC-001)
241
+ expect(pc001!.message).toContain('canon/elements/**');
242
+ expect(pc001!.message).toContain('canon/views/activities/**');
243
+ // Must include actionable hint with expected file pattern (§7 PC-001)
244
+ expect(pc001!.message).toContain('notation: action');
245
+ expect(pc001!.message).toContain(`id: "${CARD.activity_card.project}"`);
246
+ });
247
+
248
+ it('resolves without PC-001 when project activity lives in a deep canon/elements subfolder (#344)', () => {
249
+ // Regression: the resolver must accept elements regardless of which subfolder
250
+ // they were loaded from (e.g. canon/elements/05_implementation/activities/).
251
+ // The resolver is filesystem-free, so we simulate the deep-path scenario by
252
+ // including the PROJECT element in the elements pool — just as loadCanon does
253
+ // after walking canon/elements/** recursively.
254
+ const r = resolveActivityCard(CARD, sources);
255
+ expect(r.valid, JSON.stringify(r.errors)).toBe(true);
256
+ expect(r.errors.some((e) => e.code === 'PC-001')).toBe(false);
257
+ });
258
+
236
259
  it('resolves activity_type and status from the Activity element', () => {
237
260
  const r = resolveActivityCard(CARD, {
238
261
  elements: [{ ...PROJECT, activity_type: 'programme', status: 'on_track' }, CHILD, FACTOR, GOAL, CHANGE],
@@ -20,10 +20,34 @@ const VALID = {
20
20
  };
21
21
 
22
22
  describe('validateActivityCard', () => {
23
- it('accepts a well-formed card', () => {
23
+ it('accepts a well-formed card (deprecated notation: activity-card)', () => {
24
24
  const r = validateActivityCard(VALID);
25
25
  expect(r.valid).toBe(true);
26
26
  expect(r.errors).toHaveLength(0);
27
+ expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(true);
28
+ });
29
+
30
+ it('accepts notation: action-card with action_card block (canonical)', () => {
31
+ const r = validateActivityCard({
32
+ notation: 'action-card',
33
+ action_card: {
34
+ id: 'ACTION_CARD-EU-PROGRAMME-1',
35
+ project: 'ACTION-EU-PROGRAMME-1',
36
+ },
37
+ });
38
+ expect(r.valid).toBe(true);
39
+ expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(false);
40
+ });
41
+
42
+ it('accepts action_card block with legacy ACTIVITY_CARD id prefix', () => {
43
+ const r = validateActivityCard({
44
+ notation: 'action-card',
45
+ action_card: {
46
+ id: 'ACTIVITY_CARD-EU-PROGRAMME-1',
47
+ project: 'ACTION-EU-PROGRAMME-1',
48
+ },
49
+ });
50
+ expect(r.valid).toBe(true);
27
51
  });
28
52
 
29
53
  it('rejects non-object input', () => {
@@ -104,16 +104,17 @@ function collectByNotation(
104
104
  }
105
105
 
106
106
  /**
107
- * GOAL ids reached by an *active* `activity_goal` relation originating at
108
- * `fromId`. A relation with a non-null `valid_to` has ended (the activity was
109
- * re-aimed) and is excluded — the card shows the currently-served goals.
107
+ * GOAL ids reached by an *active* `action_goal` (or legacy `activity_goal`)
108
+ * relation originating at `fromId`. A relation with a non-null `valid_to` has
109
+ * ended (the activity was re-aimed) and is excluded.
110
110
  */
111
111
  function activeActivityGoals(relations: unknown[], fromId: string): string[] {
112
112
  const out: string[] = [];
113
113
  for (const doc of relations) {
114
114
  if (!isObject(doc)) continue;
115
115
  if (str(doc['notation']) !== 'relation') continue;
116
- if (str(doc['type']) !== 'activity_goal') continue;
116
+ const type = str(doc['type']);
117
+ if (type !== 'action_goal' && type !== 'activity_goal') continue;
117
118
  if (str(doc['from']) !== fromId) continue;
118
119
  const to = str(doc['to']);
119
120
  if (!to) continue;
@@ -125,11 +126,8 @@ function activeActivityGoals(relations: unknown[], fromId: string): string[] {
125
126
  }
126
127
 
127
128
  /**
128
- * STAKEHOLDER ids + roles reached by an *active* `activity_stakeholder`
129
- * relation originating at `fromId`. A relation with a non-null `valid_to` has
130
- * ended and is excluded, so the card shows the project's currently-engaged
131
- * stakeholders. The optional `role` field on the relation carries the
132
- * project-role (initiator | owner | sponsor | project_manager).
129
+ * STAKEHOLDER ids + roles reached by an *active* `action_stakeholder` (or
130
+ * legacy `activity_stakeholder`) relation originating at `fromId`.
133
131
  */
134
132
  function activeActivityStakeholders(
135
133
  relations: unknown[],
@@ -139,7 +137,8 @@ function activeActivityStakeholders(
139
137
  for (const doc of relations) {
140
138
  if (!isObject(doc)) continue;
141
139
  if (str(doc['notation']) !== 'relation') continue;
142
- if (str(doc['type']) !== 'activity_stakeholder') continue;
140
+ const type = str(doc['type']);
141
+ if (type !== 'action_stakeholder' && type !== 'activity_stakeholder') continue;
143
142
  if (str(doc['from']) !== fromId) continue;
144
143
  const to = str(doc['to']);
145
144
  if (!to) continue;
@@ -165,14 +164,22 @@ export function resolveActivityCard(
165
164
  return { valid: false, errors, warnings };
166
165
  }
167
166
 
168
- const activities = collectByNotation(sources.elements, 'activity');
169
- const projectRec = activities.get(projectId);
167
+ // Look up by canonical `notation: action` first, then legacy `notation: activity`.
168
+ const actionElements = collectByNotation(sources.elements, 'action');
169
+ const activityElements = collectByNotation(sources.elements, 'activity');
170
+ const allActionElements = new Map([...activityElements, ...actionElements]); // action wins on duplicate id
171
+ const projectRec = allActionElements.get(projectId);
170
172
 
171
- // PC-001 — project must resolve to an admitted ACTIVITY element in canon.
173
+ // PC-001 — project must resolve to an admitted ACTION (or legacy ACTIVITY)
174
+ // element after exhausting the canonical resolution scope (§6.1).
172
175
  if (!projectRec) {
173
176
  errors.push({
174
177
  code: 'PC-001',
175
- message: `activity_card.project "${projectId}" does not resolve to an ACTIVITY element in the canon element store`,
178
+ message:
179
+ `action_card.project "${projectId}" not found after searching ` +
180
+ `canon/elements/** and canon/views/activities/** — ` +
181
+ `add a YAML file with notation: action and id: "${projectId}" ` +
182
+ `(e.g. canon/elements/05_implementation/actions/${projectId}.yaml)`,
176
183
  });
177
184
  return { valid: false, errors, warnings };
178
185
  }
@@ -268,9 +275,10 @@ export function resolveActivityCard(
268
275
  }
269
276
 
270
277
  // ── Motivation chain — expand against canon DRIVER / GOAL / CHANGE elements ──
271
- // Note: YAML corpus still uses `notation: factor` and `goal.factors` field names;
272
- // the YAML keys are stable. The resolved TypeScript API uses "driver/driverIds".
273
- const driverElems = collectByNotation(sources.elements, 'factor');
278
+ // Canon elements use `notation: driver`; `notation: factor` accepted for backward compat.
279
+ const driverElemsLegacy = collectByNotation(sources.elements, 'factor');
280
+ const driverElemsNew = collectByNotation(sources.elements, 'driver');
281
+ const driverElems = new Map([...driverElemsLegacy, ...driverElemsNew]);
274
282
  const goalElems = collectByNotation(sources.elements, 'goal');
275
283
  const changeElems = collectByNotation(sources.elements, 'change');
276
284
 
@@ -342,13 +350,14 @@ export function resolveActivityCard(
342
350
  });
343
351
 
344
352
  // ── Child activities — parent = project id ──────────────────────────────────
353
+ // Look in both `notation: action` and legacy `notation: activity` elements.
345
354
  const childActivities: ResolvedChildActivity[] = [];
346
- for (const [id, rec] of activities) {
355
+ for (const [id, rec] of allActionElements) {
347
356
  if (str(rec['parent']) !== projectId) continue;
348
357
  childActivities.push({
349
358
  id,
350
359
  name: str(rec['name']) ?? id,
351
- activity_type: str(rec['activity_type']),
360
+ activity_type: str(rec['action_type']) ?? str(rec['activity_type']),
352
361
  start_date: str(rec['start_date']),
353
362
  end_date: str(rec['end_date']),
354
363
  owner: str(rec['owner']),
@@ -394,7 +403,7 @@ export function resolveActivityCard(
394
403
  valid_to: validToOk ? (validToRaw as string) : undefined,
395
404
  start_date: str(projectRec['start_date']),
396
405
  end_date: str(projectRec['end_date']),
397
- activity_type: str(projectRec['activity_type']),
406
+ activity_type: str(projectRec['action_type']) ?? str(projectRec['activity_type']),
398
407
  status: str(projectRec['status']),
399
408
  };
400
409
 
@@ -26,8 +26,8 @@ import type { ValidationError, ValidationWarning, ValidationResult } from '../va
26
26
 
27
27
  export type { ValidationError, ValidationWarning, ValidationResult };
28
28
 
29
- const ACTIVITY_CARD_ID_RE = /^ACTIVITY_CARD(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
30
- const ACTIVITY_ID_RE = /^ACTIVITY(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
29
+ const ACTIVITY_CARD_ID_RE = /^(ACTIVITY_CARD|ACTION_CARD)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
30
+ const ACTIVITY_ID_RE = /^(ACTIVITY|ACTION)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
31
31
  const MILESTONE_ID_RE = /^MILESTONE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
32
32
  const CHANGE_ID_RE = /^CHANGE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
33
33
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -49,37 +49,46 @@ export function validateActivityCard(input: unknown): ValidationResult {
49
49
  // ── Header (CONTRACT.md §2) ────────────────────────────────────────────────
50
50
  if (!('notation' in raw)) {
51
51
  errors.push({ code: 'HDR-001', message: 'notation field is required' });
52
- } else if (raw['notation'] !== 'activity-card') {
52
+ } else if (raw['notation'] !== 'action-card' && raw['notation'] !== 'activity-card') {
53
53
  errors.push({
54
54
  code: 'HDR-002',
55
- message: `notation must be "activity-card", got "${String(raw['notation'])}"`,
55
+ message: `notation must be "action-card", got "${String(raw['notation'])}"`,
56
56
  });
57
+ } else if (raw['notation'] === 'activity-card') {
58
+ warnings.push({ code: 'DEPRECATED_NOTATION', message: 'notation: "activity-card" is deprecated — migrate to notation: "action-card"' });
57
59
  }
58
60
 
59
- // ── activity_card block ─────────────────────────────────────────────────────
60
- const block = raw['activity_card'];
61
+ // ── action_card / activity_card block ───────────────────────────────────────
62
+ // "action_card:" is canonical; "activity_card:" is accepted for compat.
63
+ const rawBlock = raw['action_card'] ?? raw['activity_card'];
64
+ if (raw['activity_card'] !== undefined && raw['action_card'] === undefined) {
65
+ warnings.push({ code: 'DEPRECATED_FIELD', message: 'Root key "activity_card:" is deprecated — rename to "action_card:"' });
66
+ }
67
+ const block = rawBlock;
61
68
  if (!block || typeof block !== 'object' || Array.isArray(block)) {
62
- errors.push({ code: 'AC-001', message: 'Missing required root key: activity_card (object)' });
69
+ errors.push({ code: 'AC-001', message: 'Missing required root key: action_card (object)' });
63
70
  return { valid: false, errors, warnings };
64
71
  }
72
+ // Normalise onto activity_card for downstream (resolver reads doc.activity_card)
73
+ if (raw['activity_card'] === undefined) raw['activity_card'] = rawBlock;
65
74
  const card = block as Record<string, unknown>;
66
75
 
67
76
  if (!isNonEmptyString(card['id'])) {
68
- errors.push({ code: 'AC-002', message: 'activity_card.id is required' });
77
+ errors.push({ code: 'AC-002', message: 'action_card.id is required' });
69
78
  } else if (!ACTIVITY_CARD_ID_RE.test(card['id'])) {
70
79
  errors.push({
71
80
  code: 'AC-002',
72
- message: `activity_card.id "${card['id']}" must match ACTIVITY_CARD-[<middle>-]<INTEGER>`,
81
+ message: `action_card.id "${card['id']}" must match ACTION_CARD-[<middle>-]<INTEGER> (or legacy ACTIVITY_CARD-…)`,
73
82
  });
74
83
  }
75
84
 
76
85
  // PC-001 (document-local half): project present + well-formed.
77
86
  if (!isNonEmptyString(card['project'])) {
78
- errors.push({ code: 'PC-001', message: 'activity_card.project is required (an ACTIVITY-… id)' });
87
+ errors.push({ code: 'PC-001', message: 'action_card.project is required (an ACTION-… id)' });
79
88
  } else if (!ACTIVITY_ID_RE.test(card['project'])) {
80
89
  errors.push({
81
90
  code: 'PC-001',
82
- message: `activity_card.project "${card['project']}" is malformed; must match ACTIVITY-[<middle>-]<INTEGER>`,
91
+ message: `action_card.project "${card['project']}" is malformed; must match ACTION-[<middle>-]<INTEGER> (or legacy ACTIVITY-…)`,
83
92
  });
84
93
  }
85
94
 
@@ -6,3 +6,14 @@ export type {
6
6
  } from './types.js';
7
7
 
8
8
  export { validateCapabilityMap } from './validate.js';
9
+
10
+ export type {
11
+ CapabilityTreeNode,
12
+ CapabilityTreeEdge,
13
+ CapabilityTreeLevelCounts,
14
+ CapabilityTreeLayout,
15
+ } from './layout-tree.js';
16
+ export { layoutCapabilityTree, TREE_NODE_WIDTH, TREE_NODE_HEIGHT, TREE_RANK_SEP, TREE_NODE_SEP } from './layout-tree.js';
17
+
18
+ export type { RenderCapabilityTreeOptions } from './render-capability-tree.js';
19
+ export { renderCapabilityTreeSvg } from './render-capability-tree.js';
@@ -0,0 +1,114 @@
1
+ import type { CapabilityNode, CapabilityMapHeader } from './types.js';
2
+
3
+ export const TREE_NODE_WIDTH = 250;
4
+ export const TREE_NODE_HEIGHT = 64;
5
+ /** Gap between sibling columns (node width + rank sep = 330px, matching DSM). */
6
+ export const TREE_RANK_SEP = 80;
7
+ /** Vertical gap between sibling nodes. */
8
+ export const TREE_NODE_SEP = 24;
9
+
10
+ export interface CapabilityTreeNode {
11
+ id: string;
12
+ x: number;
13
+ y: number;
14
+ width: number;
15
+ height: number;
16
+ data: CapabilityNode;
17
+ depth: number;
18
+ hasChildren: boolean;
19
+ isCollapsed: boolean;
20
+ }
21
+
22
+ export interface CapabilityTreeEdge {
23
+ source: string;
24
+ target: string;
25
+ }
26
+
27
+ export interface CapabilityTreeLevelCounts {
28
+ /** Nodes at depth 0–2 (pink band). */
29
+ band0: number;
30
+ /** Nodes at depth 3–4 (yellow band). */
31
+ band1: number;
32
+ /** Nodes at depth 5+ (blue band). */
33
+ band2: number;
34
+ }
35
+
36
+ export interface CapabilityTreeLayout {
37
+ nodes: CapabilityTreeNode[];
38
+ edges: CapabilityTreeEdge[];
39
+ bounds: { x: number; y: number; width: number; height: number };
40
+ levelCounts: CapabilityTreeLevelCounts;
41
+ }
42
+
43
+ export function layoutCapabilityTree(
44
+ map: CapabilityMapHeader,
45
+ collapsedIds: Set<string> = new Set(),
46
+ ): CapabilityTreeLayout {
47
+ const nodes: CapabilityTreeNode[] = [];
48
+ const edges: CapabilityTreeEdge[] = [];
49
+
50
+ const colNextY = new Map<number, number>();
51
+ function getNextY(col: number): number {
52
+ return colNextY.get(col) ?? 0;
53
+ }
54
+ function advanceY(col: number, delta: number): void {
55
+ colNextY.set(col, (colNextY.get(col) ?? 0) + delta);
56
+ }
57
+
58
+ function placeNode(node: CapabilityNode, depth: number): { top: number; bottom: number } {
59
+ const isCollapsed = collapsedIds.has(node.id);
60
+ const hasChildren = Boolean(node.children?.length);
61
+ const visibleChildren = !isCollapsed && node.children?.length ? node.children : [];
62
+
63
+ const x = depth * (TREE_NODE_WIDTH + TREE_RANK_SEP);
64
+
65
+ if (visibleChildren.length === 0) {
66
+ const y = getNextY(depth);
67
+ advanceY(depth, TREE_NODE_HEIGHT + TREE_NODE_SEP);
68
+ nodes.push({ id: node.id, x, y, width: TREE_NODE_WIDTH, height: TREE_NODE_HEIGHT, data: node, depth, hasChildren, isCollapsed });
69
+ return { top: y, bottom: y + TREE_NODE_HEIGHT };
70
+ }
71
+
72
+ const childSpans: Array<{ top: number; bottom: number }> = [];
73
+ for (const child of visibleChildren) {
74
+ childSpans.push(placeNode(child, depth + 1));
75
+ edges.push({ source: node.id, target: child.id });
76
+ }
77
+
78
+ const spanTop = Math.min(...childSpans.map(s => s.top));
79
+ const spanBottom = Math.max(...childSpans.map(s => s.bottom));
80
+ const parentY = spanTop + (spanBottom - spanTop) / 2 - TREE_NODE_HEIGHT / 2;
81
+
82
+ const curColY = getNextY(depth);
83
+ const finalY = Math.max(parentY, curColY);
84
+ advanceY(depth, finalY - curColY + TREE_NODE_HEIGHT + TREE_NODE_SEP);
85
+
86
+ nodes.push({ id: node.id, x, y: finalY, width: TREE_NODE_WIDTH, height: TREE_NODE_HEIGHT, data: node, depth, hasChildren, isCollapsed });
87
+ return { top: Math.min(finalY, spanTop), bottom: Math.max(finalY + TREE_NODE_HEIGHT, spanBottom) };
88
+ }
89
+
90
+ for (const root of map.capabilities) {
91
+ placeNode(root, 0);
92
+ for (const [col, y] of colNextY) {
93
+ colNextY.set(col, y + TREE_NODE_SEP);
94
+ }
95
+ }
96
+
97
+ if (nodes.length === 0) {
98
+ return { nodes: [], edges: [], bounds: { x: 0, y: 0, width: 0, height: 0 }, levelCounts: { band0: 0, band1: 0, band2: 0 } };
99
+ }
100
+
101
+ const minX = Math.min(...nodes.map(n => n.x));
102
+ const minY = Math.min(...nodes.map(n => n.y));
103
+ const maxX = Math.max(...nodes.map(n => n.x + n.width));
104
+ const maxY = Math.max(...nodes.map(n => n.y + n.height));
105
+
106
+ const levelCounts: CapabilityTreeLevelCounts = { band0: 0, band1: 0, band2: 0 };
107
+ for (const n of nodes) {
108
+ if (n.depth <= 2) levelCounts.band0++;
109
+ else if (n.depth <= 4) levelCounts.band1++;
110
+ else levelCounts.band2++;
111
+ }
112
+
113
+ return { nodes, edges, bounds: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, levelCounts };
114
+ }
@@ -0,0 +1,131 @@
1
+ import type { CapabilityMapHeader } from './types.js';
2
+ import { layoutCapabilityTree, TREE_NODE_WIDTH, TREE_NODE_HEIGHT, TREE_NODE_SEP } from './layout-tree.js';
3
+ import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
4
+ import { escXml } from '../webview/render-util.js';
5
+
6
+ const PAD = 24;
7
+ const BTN_R = 8;
8
+ const LEGEND_H = 40;
9
+ const LABEL_MAX_CHARS = 28;
10
+ const LABEL_TRIM_CHARS = 26;
11
+
12
+ const BAND_LABELS = ['Levels 0–2', 'Levels 3–4', 'Levels 5+'] as const;
13
+
14
+ function depthBand(depth: number): 0 | 1 | 2 {
15
+ if (depth <= 2) return 0;
16
+ if (depth <= 4) return 1;
17
+ return 2;
18
+ }
19
+
20
+ function truncate(s: string, max: number, trim: number): string {
21
+ return s.length > max ? s.slice(0, trim) + '…' : s;
22
+ }
23
+
24
+ export interface RenderCapabilityTreeOptions {
25
+ collapsedIds?: Set<string>;
26
+ curvature?: number;
27
+ }
28
+
29
+ /**
30
+ * Renders a capability-map as a left-to-right SVG node tree with DSM-matching
31
+ * visual design: depth-banded fills, maturity badges, and +/− collapse buttons.
32
+ *
33
+ * The SVG uses CSS classes (`tree-level-N`, `tree-maturity-N`, `tree-collapse-btn`)
34
+ * that must be resolved by the host's stylesheet (injected via `diagramClassCss()`
35
+ * in themes.ts). Collapse buttons carry `data-node-id` / `data-collapsed` attributes
36
+ * for the webview click handler.
37
+ */
38
+ export function renderCapabilityTreeSvg(
39
+ map: CapabilityMapHeader,
40
+ opts: RenderCapabilityTreeOptions = {},
41
+ ): string {
42
+ const { collapsedIds = new Set(), curvature = DEFAULT_EDGE_CURVATURE } = opts;
43
+ const layout = layoutCapabilityTree(map, collapsedIds);
44
+
45
+ if (layout.nodes.length === 0) {
46
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
47
+ }
48
+
49
+ const ox = -layout.bounds.x + PAD;
50
+ const oy = -layout.bounds.y + PAD;
51
+ const svgW = layout.bounds.width + PAD * 2 + BTN_R;
52
+ const svgH = layout.bounds.height + PAD * 2 + LEGEND_H + TREE_NODE_SEP;
53
+
54
+ const nodeMap = new Map(layout.nodes.map(n => [n.id, n]));
55
+
56
+ const edgeSvg = layout.edges.map(e => {
57
+ const s = nodeMap.get(e.source);
58
+ const t = nodeMap.get(e.target);
59
+ if (!s || !t) return '';
60
+ const sx = s.x + ox + s.width;
61
+ const sy = s.y + oy + s.height / 2;
62
+ const tx = t.x + ox;
63
+ const ty = t.y + oy + t.height / 2;
64
+ return `<path d="${horizontalCubicEdgePath(sx, sy, tx, ty, curvature)}" class="diagram-edge"/>`;
65
+ }).filter(Boolean).join('\n');
66
+
67
+ const nodeSvg = layout.nodes.map(n => {
68
+ const x = n.x + ox;
69
+ const y = n.y + oy;
70
+ const band = depthBand(n.depth);
71
+ const mat = Math.max(1, Math.min(5, n.data.current_maturity | 0));
72
+
73
+ const nameText = truncate(n.data.name, LABEL_MAX_CHARS, LABEL_TRIM_CHARS);
74
+ const nameTitle = n.data.name.length > LABEL_MAX_CHARS ? escXml(n.data.name) : '';
75
+
76
+ // Maturity badge: left side of node
77
+ const badgeW = 28;
78
+ const badgeH = 18;
79
+ const badgeX = x + 10;
80
+ const badgeY = y + (TREE_NODE_HEIGHT - badgeH) / 2;
81
+
82
+ // Text area: right of badge
83
+ const textX = badgeX + badgeW + 8;
84
+ const nameY = y + TREE_NODE_HEIGHT / 2 - 8;
85
+ const idY = y + TREE_NODE_HEIGHT / 2 + 9;
86
+
87
+ // Collapse button: centered on right edge of node
88
+ const btnCx = x + TREE_NODE_WIDTH;
89
+ const btnCy = y + TREE_NODE_HEIGHT / 2;
90
+
91
+ const collapseBtn = n.hasChildren
92
+ ? `<circle class="tree-collapse-btn" cx="${btnCx}" cy="${btnCy}" r="${BTN_R}" data-node-id="${escXml(n.id)}" data-collapsed="${n.isCollapsed}"/>
93
+ <text class="tree-collapse-lbl" x="${btnCx}" y="${btnCy}" text-anchor="middle">${n.isCollapsed ? '+' : '−'}</text>`
94
+ : '';
95
+
96
+ return `<g${nameTitle ? ` title="${nameTitle}"` : ''}>
97
+ <rect class="tree-level-${band}" x="${x}" y="${y}" width="${TREE_NODE_WIDTH}" height="${TREE_NODE_HEIGHT}" rx="11" stroke="var(--ts-node-stroke,#94a3b8)" stroke-width="3"/>
98
+ <rect class="tree-maturity-${mat}" x="${badgeX}" y="${badgeY}" width="${badgeW}" height="${badgeH}" rx="4"/>
99
+ <text class="text-pill" x="${badgeX + badgeW / 2}" y="${badgeY + badgeH / 2}" text-anchor="middle" fill="white">L${mat}</text>
100
+ <text class="text-primary" x="${textX}" y="${nameY}" dominant-baseline="central">${escXml(nameText)}</text>
101
+ <text class="text-id" x="${textX}" y="${idY}" dominant-baseline="central">${escXml(n.id)}</text>
102
+ ${collapseBtn}
103
+ </g>`;
104
+ }).join('\n');
105
+
106
+ // Legend band: proportional coloured blocks per level-count band
107
+ const legendY = layout.bounds.height + PAD * 2 + TREE_NODE_SEP / 2;
108
+ const legendW = svgW - PAD * 2;
109
+ const totalNodes = layout.nodes.length || 1;
110
+ const bandCounts = [layout.levelCounts.band0, layout.levelCounts.band1, layout.levelCounts.band2];
111
+ let legendX = PAD;
112
+ const legendSvg = bandCounts.map((count, i) => {
113
+ const bw = Math.max(1, Math.round(legendW * count / totalNodes));
114
+ const bx = legendX;
115
+ legendX += bw;
116
+ if (count === 0) return '';
117
+ return `<rect class="tree-level-${i}" x="${bx}" y="${legendY}" width="${bw}" height="24" rx="4" stroke="var(--ts-node-stroke,#94a3b8)" stroke-width="1"/>
118
+ <text class="text-id" x="${bx + bw / 2}" y="${legendY + 12}" text-anchor="middle" dominant-baseline="central">${escXml(BAND_LABELS[i])}</text>`;
119
+ }).filter(Boolean).join('\n');
120
+
121
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">
122
+ <defs>
123
+ <marker id="cap-tree-arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
124
+ <path d="M0,0 L0,6 L8,3 z" class="arrow-fill"/>
125
+ </marker>
126
+ </defs>
127
+ ${edgeSvg}
128
+ ${nodeSvg}
129
+ ${legendSvg}
130
+ </svg>`;
131
+ }
@@ -25,5 +25,7 @@ export interface CapabilityMapHeader {
25
25
  export interface CapabilityMapFile {
26
26
  notation: string;
27
27
  spec_version?: string;
28
+ /** Render mode for Studio previews. Defaults to 'cards'. */
29
+ view?: 'cards' | 'tree';
28
30
  capability_map: CapabilityMapHeader;
29
31
  }