@transitrix/diagrams 1.1.0 → 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 (42) hide show
  1. package/dist/activities/__tests__/validate.test.js +32 -1
  2. package/dist/activities/validate.js +21 -5
  3. package/dist/activity-card/__tests__/resolver.test.js +1 -1
  4. package/dist/activity-card/__tests__/validate.test.js +23 -1
  5. package/dist/activity-card/resolver.js +23 -21
  6. package/dist/activity-card/validate.js +22 -11
  7. package/dist/capability-map/index.d.ts +4 -0
  8. package/dist/capability-map/index.js +2 -0
  9. package/dist/capability-map/layout-tree.d.ts +42 -0
  10. package/dist/capability-map/layout-tree.js +65 -0
  11. package/dist/capability-map/render-capability-tree.d.ts +15 -0
  12. package/dist/capability-map/render-capability-tree.js +108 -0
  13. package/dist/capability-map/types.d.ts +2 -0
  14. package/dist/fgca/__tests__/validate.test.js +2 -2
  15. package/dist/fgca/parse-canonical.js +9 -3
  16. package/dist/fgca/types.d.ts +2 -0
  17. package/dist/fgca/validate.js +19 -11
  18. package/dist/theme/index.d.ts +1 -1
  19. package/dist/theme/index.js +1 -1
  20. package/dist/theme/themes.js +26 -3
  21. package/dist/theme/tokens.d.ts +40 -0
  22. package/dist/theme/tokens.js +28 -0
  23. package/dist/webview/render-fgca.js +1 -1
  24. package/package.json +1 -1
  25. package/src/activities/__tests__/validate.test.ts +35 -1
  26. package/src/activities/validate.ts +21 -5
  27. package/src/activity-card/__tests__/resolver.test.ts +1 -1
  28. package/src/activity-card/__tests__/validate.test.ts +25 -1
  29. package/src/activity-card/resolver.ts +23 -21
  30. package/src/activity-card/validate.ts +20 -11
  31. package/src/capability-map/index.ts +11 -0
  32. package/src/capability-map/layout-tree.ts +114 -0
  33. package/src/capability-map/render-capability-tree.ts +131 -0
  34. package/src/capability-map/types.ts +2 -0
  35. package/src/fgca/__tests__/validate.test.ts +2 -2
  36. package/src/fgca/parse-canonical.ts +9 -3
  37. package/src/fgca/types.ts +2 -0
  38. package/src/fgca/validate.ts +16 -8
  39. package/src/theme/index.ts +1 -1
  40. package/src/theme/themes.ts +27 -2
  41. package/src/theme/tokens.ts +30 -0
  42. package/src/webview/render-fgca.ts +1 -1
@@ -22,10 +22,18 @@ export function validateFGCADoc(input) {
22
22
  errors.push({ code: 'SCHEMA_INVALID', message: 'goals must be an array', path: 'goals' });
23
23
  if (!Array.isArray(raw.changes))
24
24
  errors.push({ code: 'SCHEMA_INVALID', message: 'changes must be an array', path: 'changes' });
25
- if (!Array.isArray(raw.activities))
26
- errors.push({ code: 'SCHEMA_INVALID', message: 'activities must be an array', path: 'activities' });
25
+ // Accept canonical `actions:` or deprecated `activities:`; warn on the latter.
26
+ const hasActions = Array.isArray(raw.actions);
27
+ const hasActivities = Array.isArray(raw.activities);
28
+ if (!hasActions && !hasActivities) {
29
+ errors.push({ code: 'SCHEMA_INVALID', message: 'actions must be an array', path: 'actions' });
30
+ }
31
+ else if ('activities' in raw) {
32
+ warnings.push({ code: 'DEPRECATED_NOTATION', message: '"activities" key is deprecated — rename to "actions"' });
33
+ }
27
34
  if (errors.length > 0)
28
35
  return { valid: false, errors, warnings };
36
+ const actionsArr = (hasActions ? raw.actions : raw.activities);
29
37
  const doc = raw;
30
38
  const collectIds = (arr) => {
31
39
  const out = new Set();
@@ -40,7 +48,7 @@ export function validateFGCADoc(input) {
40
48
  };
41
49
  const factorIds = collectIds(doc.factors);
42
50
  const goalIds = collectIds(doc.goals);
43
- const activityIds = collectIds(doc.activities);
51
+ const activityIds = collectIds(actionsArr);
44
52
  for (let i = 0; i < doc.factors.length; i++) {
45
53
  const f = doc.factors[i];
46
54
  if (!f || typeof f !== 'object') {
@@ -95,17 +103,17 @@ export function validateFGCADoc(input) {
95
103
  }
96
104
  }
97
105
  }
98
- for (let i = 0; i < doc.activities.length; i++) {
99
- const a = doc.activities[i];
106
+ for (let i = 0; i < actionsArr.length; i++) {
107
+ const a = actionsArr[i];
100
108
  if (!a || typeof a !== 'object') {
101
- errors.push({ code: 'SCHEMA_INVALID', message: 'activity entry must be an object', path: `activities[${i}]` });
109
+ errors.push({ code: 'SCHEMA_INVALID', message: 'action entry must be an object', path: `actions[${i}]` });
102
110
  continue;
103
111
  }
104
- const activity = a;
105
- if (typeof activity.id !== 'number' && typeof activity.id !== 'string')
106
- errors.push({ code: 'SCHEMA_INVALID', message: 'activity id must be a number or string', path: `activities[${i}]` });
107
- if (typeof activity.name !== 'string' || !activity.name.trim())
108
- errors.push({ code: 'EMPTY_NAME', message: `Activity ${String(activity.id)} has empty name`, path: `activities[${i}]` });
112
+ const action = a;
113
+ if (typeof action.id !== 'number' && typeof action.id !== 'string')
114
+ errors.push({ code: 'SCHEMA_INVALID', message: 'action id must be a number or string', path: `actions[${i}]` });
115
+ if (typeof action.name !== 'string' || !action.name.trim())
116
+ errors.push({ code: 'EMPTY_NAME', message: `Action ${String(action.id)} has empty name`, path: `actions[${i}]` });
109
117
  }
110
118
  return { valid: errors.length === 0, errors, warnings };
111
119
  }
@@ -1,4 +1,4 @@
1
1
  export type { AdaptiveTokens } from './tokens.js';
2
- export { BRAND, FUNCTIONAL, LAYER_COLORS, LEVEL_COLORS, MATURITY_COLORS, STRUCTURAL, TYPOGRAPHY, CSS_VAR, getBaseResetCss } from './tokens.js';
2
+ export { BRAND, FUNCTIONAL, LAYER_COLORS, LEVEL_COLORS, MATURITY_COLORS, TREE_LEVEL_COLORS, TREE_MATURITY_COLORS, STRUCTURAL, TYPOGRAPHY, CSS_VAR, getBaseResetCss } from './tokens.js';
3
3
  export type { ThemeId } from './themes.js';
4
4
  export { generateWebviewCss, generateSvgEmbedCss } from './themes.js';
@@ -1,2 +1,2 @@
1
- export { BRAND, FUNCTIONAL, LAYER_COLORS, LEVEL_COLORS, MATURITY_COLORS, STRUCTURAL, TYPOGRAPHY, CSS_VAR, getBaseResetCss } from './tokens.js';
1
+ export { BRAND, FUNCTIONAL, LAYER_COLORS, LEVEL_COLORS, MATURITY_COLORS, TREE_LEVEL_COLORS, TREE_MATURITY_COLORS, STRUCTURAL, TYPOGRAPHY, CSS_VAR, getBaseResetCss } from './tokens.js';
2
2
  export { generateWebviewCss, generateSvgEmbedCss } from './themes.js';
@@ -1,4 +1,4 @@
1
- import { LAYER_COLORS, LEVEL_COLORS, MATURITY_COLORS, STRUCTURAL, TYPOGRAPHY, CSS_VAR, getBaseResetCss, } from './tokens.js';
1
+ import { LAYER_COLORS, LEVEL_COLORS, MATURITY_COLORS, TREE_LEVEL_COLORS, TREE_MATURITY_COLORS, STRUCTURAL, TYPOGRAPHY, CSS_VAR, getBaseResetCss, } from './tokens.js';
2
2
  const TRANSITRIX_LIGHT = {
3
3
  bg: '#ffffff',
4
4
  bgSurface: '#f8fafc',
@@ -60,7 +60,19 @@ function diagramVars(variant) {
60
60
  const sc = STRUCTURAL;
61
61
  const lv = LEVEL_COLORS[variant];
62
62
  const mat = MATURITY_COLORS[variant];
63
+ const treeLevel = TREE_LEVEL_COLORS;
64
+ const treeMat = TREE_MATURITY_COLORS[variant];
63
65
  const levelVars = lv.map((c, i) => `--ts-level-${i}:${c}`).join(';') + ';';
66
+ const treeVars = [
67
+ `${CSS_VAR.treeLevel0}:${treeLevel.band0[variant]}`,
68
+ `${CSS_VAR.treeLevel1}:${treeLevel.band1[variant]}`,
69
+ `${CSS_VAR.treeLevel2}:${treeLevel.band2[variant]}`,
70
+ `${CSS_VAR.treeMaturity1}:${treeMat[0]}`,
71
+ `${CSS_VAR.treeMaturity2}:${treeMat[1]}`,
72
+ `${CSS_VAR.treeMaturity3}:${treeMat[2]}`,
73
+ `${CSS_VAR.treeMaturity4}:${treeMat[3]}`,
74
+ `${CSS_VAR.treeMaturity5}:${treeMat[4]}`,
75
+ ].join(';') + ';';
64
76
  return [
65
77
  `${CSS_VAR.layerDriver}:${lc.driver[variant]}`,
66
78
  `${CSS_VAR.layerFactor}:${lc.factor[variant]}`,
@@ -77,7 +89,7 @@ function diagramVars(variant) {
77
89
  `${CSS_VAR.maturity3}:${mat[2]}`,
78
90
  `${CSS_VAR.maturity4}:${mat[3]}`,
79
91
  `${CSS_VAR.maturity5}:${mat[4]}`,
80
- ].join(';') + ';' + levelVars;
92
+ ].join(';') + ';' + levelVars + treeVars;
81
93
  }
82
94
  /** CSS for the webview shell (toolbar, canvas). */
83
95
  function shellCss() {
@@ -136,7 +148,18 @@ ${levelRules}
136
148
  .compliance-deadline{fill:var(${cv.statusErrorBg});stroke:var(${cv.statusErrorFg});}
137
149
  .compliance-deadline text{fill:var(${cv.statusErrorFg});}
138
150
  .compliance-badge{fill:var(${cv.statusErrorFg});}
139
- .compliance-badge-text{fill:var(${cv.textInverse});font-family:${t.fontFamily};font-size:8px;font-weight:700;}`;
151
+ .compliance-badge-text{fill:var(${cv.textInverse});font-family:${t.fontFamily};font-size:8px;font-weight:700;}
152
+ .tree-level-0{fill:var(${cv.treeLevel0});}
153
+ .tree-level-1{fill:var(${cv.treeLevel1});}
154
+ .tree-level-2{fill:var(${cv.treeLevel2});}
155
+ .tree-maturity-1{fill:var(${cv.treeMaturity1});}
156
+ .tree-maturity-2{fill:var(${cv.treeMaturity2});}
157
+ .tree-maturity-3{fill:var(${cv.treeMaturity3});}
158
+ .tree-maturity-4{fill:var(${cv.treeMaturity4});}
159
+ .tree-maturity-5{fill:var(${cv.treeMaturity5});}
160
+ .tree-collapse-btn{cursor:pointer;fill:white;stroke:var(${cv.nodeStroke});stroke-width:1.5;}
161
+ .tree-collapse-btn:hover{fill:var(${cv.bgElevated});}
162
+ .tree-collapse-lbl{fill:var(${cv.textSecondary});font-family:${t.fontFamily};font-size:12px;font-weight:700;dominant-baseline:central;pointer-events:none;}`;
140
163
  }
141
164
  /**
142
165
  * Generates a self-contained CSS block for embedding inside a standalone SVG file.
@@ -82,6 +82,38 @@ export declare const MATURITY_COLORS: {
82
82
  readonly dark: readonly ["#7f1d1d", "#92400e", "#854d0e", "#3f6212", "#14532d"];
83
83
  readonly hc: readonly ["#f87171", "#fb923c", "#facc15", "#a3e635", "#4ade80"];
84
84
  };
85
+ /**
86
+ * DSM-matching capability tree node fill colors, grouped by depth band.
87
+ * band0: depth 0–2 (pink)
88
+ * band1: depth 3–4 (yellow)
89
+ * band2: depth 5+ (blue)
90
+ */
91
+ export declare const TREE_LEVEL_COLORS: {
92
+ readonly band0: {
93
+ readonly light: "#fee0e0";
94
+ readonly dark: "#3d0f0f";
95
+ readonly hc: "#ff9999";
96
+ };
97
+ readonly band1: {
98
+ readonly light: "#ffffcb";
99
+ readonly dark: "#3a3500";
100
+ readonly hc: "#ffff88";
101
+ };
102
+ readonly band2: {
103
+ readonly light: "#c2f0ff";
104
+ readonly dark: "#003d4d";
105
+ readonly hc: "#66ccff";
106
+ };
107
+ };
108
+ /**
109
+ * DSM-matching maturity badge fills for the capability tree (L1–L5).
110
+ * Distinct from MATURITY_COLORS which drives the cards / compliance views.
111
+ */
112
+ export declare const TREE_MATURITY_COLORS: {
113
+ readonly light: readonly ["#c1c1c1", "#ff3d67", "#ff9a59", "#fdef59", "#83cca3"];
114
+ readonly dark: readonly ["#555555", "#cc1040", "#d06020", "#c8c000", "#4d9970"];
115
+ readonly hc: readonly ["#cccccc", "#ff6699", "#ffbb77", "#ffff44", "#88ddaa"];
116
+ };
85
117
  /**
86
118
  * Typography hierarchy. All previews resolve text styling from these roles
87
119
  * — never from inline font-* attributes on individual <text> elements.
@@ -160,5 +192,13 @@ export declare const CSS_VAR: {
160
192
  readonly maturity3: "--ts-maturity-3";
161
193
  readonly maturity4: "--ts-maturity-4";
162
194
  readonly maturity5: "--ts-maturity-5";
195
+ readonly treeLevel0: "--ts-tree-level-0";
196
+ readonly treeLevel1: "--ts-tree-level-1";
197
+ readonly treeLevel2: "--ts-tree-level-2";
198
+ readonly treeMaturity1: "--ts-tree-maturity-1";
199
+ readonly treeMaturity2: "--ts-tree-maturity-2";
200
+ readonly treeMaturity3: "--ts-tree-maturity-3";
201
+ readonly treeMaturity4: "--ts-tree-maturity-4";
202
+ readonly treeMaturity5: "--ts-tree-maturity-5";
163
203
  };
164
204
  export declare function getBaseResetCss(): string;
@@ -42,6 +42,26 @@ export const MATURITY_COLORS = {
42
42
  dark: ['#7f1d1d', '#92400e', '#854d0e', '#3f6212', '#14532d'],
43
43
  hc: ['#f87171', '#fb923c', '#facc15', '#a3e635', '#4ade80'],
44
44
  };
45
+ /**
46
+ * DSM-matching capability tree node fill colors, grouped by depth band.
47
+ * band0: depth 0–2 (pink)
48
+ * band1: depth 3–4 (yellow)
49
+ * band2: depth 5+ (blue)
50
+ */
51
+ export const TREE_LEVEL_COLORS = {
52
+ band0: { light: '#fee0e0', dark: '#3d0f0f', hc: '#ff9999' },
53
+ band1: { light: '#ffffcb', dark: '#3a3500', hc: '#ffff88' },
54
+ band2: { light: '#c2f0ff', dark: '#003d4d', hc: '#66ccff' },
55
+ };
56
+ /**
57
+ * DSM-matching maturity badge fills for the capability tree (L1–L5).
58
+ * Distinct from MATURITY_COLORS which drives the cards / compliance views.
59
+ */
60
+ export const TREE_MATURITY_COLORS = {
61
+ light: ['#c1c1c1', '#ff3d67', '#ff9a59', '#fdef59', '#83cca3'],
62
+ dark: ['#555555', '#cc1040', '#d06020', '#c8c000', '#4d9970'],
63
+ hc: ['#cccccc', '#ff6699', '#ffbb77', '#ffff44', '#88ddaa'],
64
+ };
45
65
  /**
46
66
  * Typography hierarchy. All previews resolve text styling from these roles
47
67
  * — never from inline font-* attributes on individual <text> elements.
@@ -101,6 +121,14 @@ export const CSS_VAR = {
101
121
  maturity3: '--ts-maturity-3',
102
122
  maturity4: '--ts-maturity-4',
103
123
  maturity5: '--ts-maturity-5',
124
+ treeLevel0: '--ts-tree-level-0',
125
+ treeLevel1: '--ts-tree-level-1',
126
+ treeLevel2: '--ts-tree-level-2',
127
+ treeMaturity1: '--ts-tree-maturity-1',
128
+ treeMaturity2: '--ts-tree-maturity-2',
129
+ treeMaturity3: '--ts-tree-maturity-3',
130
+ treeMaturity4: '--ts-tree-maturity-4',
131
+ treeMaturity5: '--ts-tree-maturity-5',
104
132
  };
105
133
  export function getBaseResetCss() {
106
134
  return `*,*:before,*:after{box-sizing:border-box;}html,body{margin:0;padding:0;}`;
@@ -20,7 +20,7 @@ const COL_LABELS = {
20
20
  driver: 'Drivers (D)',
21
21
  goal: 'Goals (G)',
22
22
  change: 'Changes (C)',
23
- activity: 'Activities (A)',
23
+ activity: 'Actions (A)',
24
24
  };
25
25
  /**
26
26
  * Canonical FGCA/FGA body — the column headers, node rects and edge paths that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
@@ -15,9 +15,43 @@ const minimalValid = {
15
15
  };
16
16
 
17
17
  describe('validateActivities', () => {
18
- it('ACT-001 — accepts valid notation field', () => {
18
+ it('ACT-001 — accepts notation: activities (deprecated)', () => {
19
19
  const r = validateActivities(minimalValid);
20
20
  expect(r.valid).toBe(true);
21
+ // deprecated notation emits a warning, not an error
22
+ expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(true);
23
+ });
24
+
25
+ it('ACT-001 — accepts notation: action (canonical)', () => {
26
+ const r = validateActivities({
27
+ notation: 'action',
28
+ actions: [
29
+ { id: 'ACTION-001', name: 'Start', duration: 3 },
30
+ { id: 'ACTION-002', name: 'End', duration: 2, predecessors: ['ACTION-001'] },
31
+ ],
32
+ });
33
+ expect(r.valid).toBe(true);
34
+ expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(false);
35
+ });
36
+
37
+ it('ACT-001 — accepts notation: action with deprecated activities: key (emits field warning)', () => {
38
+ const r = validateActivities({
39
+ notation: 'action',
40
+ activities: [{ id: 'ACTION-001', name: 'Foo', duration: 1 }],
41
+ });
42
+ expect(r.valid).toBe(true);
43
+ expect(r.warnings.some(w => w.code === 'DEPRECATED_FIELD')).toBe(true);
44
+ });
45
+
46
+ it('ACT-001 — normalises action_type to activity_type for downstream layout', () => {
47
+ const input: Record<string, unknown> = {
48
+ notation: 'action',
49
+ actions: [{ id: 'ACTION-001', name: 'Foo', duration: 1, action_type: 'project' }],
50
+ };
51
+ const r = validateActivities(input);
52
+ expect(r.valid).toBe(true);
53
+ const entry = (input.actions as Array<Record<string, unknown>>)[0];
54
+ expect(entry['activity_type']).toBe('project');
21
55
  });
22
56
 
23
57
  it('ACT-001 — rejects missing notation', () => {
@@ -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 });
@@ -241,7 +241,7 @@ describe('resolveActivityCard', () => {
241
241
  expect(pc001!.message).toContain('canon/elements/**');
242
242
  expect(pc001!.message).toContain('canon/views/activities/**');
243
243
  // Must include actionable hint with expected file pattern (§7 PC-001)
244
- expect(pc001!.message).toContain('notation: activity');
244
+ expect(pc001!.message).toContain('notation: action');
245
245
  expect(pc001!.message).toContain(`id: "${CARD.activity_card.project}"`);
246
246
  });
247
247
 
@@ -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,20 +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 after
172
- // exhausting the canonical resolution scope (§6.1): canon/elements/**
173
- // recursively first, then canon/views/activities/** as secondary fallback.
173
+ // PC-001 — project must resolve to an admitted ACTION (or legacy ACTIVITY)
174
+ // element after exhausting the canonical resolution scope (§6.1).
174
175
  if (!projectRec) {
175
176
  errors.push({
176
177
  code: 'PC-001',
177
178
  message:
178
- `activity_card.project "${projectId}" not found after searching ` +
179
+ `action_card.project "${projectId}" not found after searching ` +
179
180
  `canon/elements/** and canon/views/activities/** — ` +
180
- `add a YAML file with notation: activity and id: "${projectId}" ` +
181
- `(e.g. canon/elements/05_implementation/activities/${projectId}.yaml)`,
181
+ `add a YAML file with notation: action and id: "${projectId}" ` +
182
+ `(e.g. canon/elements/05_implementation/actions/${projectId}.yaml)`,
182
183
  });
183
184
  return { valid: false, errors, warnings };
184
185
  }
@@ -349,13 +350,14 @@ export function resolveActivityCard(
349
350
  });
350
351
 
351
352
  // ── Child activities — parent = project id ──────────────────────────────────
353
+ // Look in both `notation: action` and legacy `notation: activity` elements.
352
354
  const childActivities: ResolvedChildActivity[] = [];
353
- for (const [id, rec] of activities) {
355
+ for (const [id, rec] of allActionElements) {
354
356
  if (str(rec['parent']) !== projectId) continue;
355
357
  childActivities.push({
356
358
  id,
357
359
  name: str(rec['name']) ?? id,
358
- activity_type: str(rec['activity_type']),
360
+ activity_type: str(rec['action_type']) ?? str(rec['activity_type']),
359
361
  start_date: str(rec['start_date']),
360
362
  end_date: str(rec['end_date']),
361
363
  owner: str(rec['owner']),
@@ -401,7 +403,7 @@ export function resolveActivityCard(
401
403
  valid_to: validToOk ? (validToRaw as string) : undefined,
402
404
  start_date: str(projectRec['start_date']),
403
405
  end_date: str(projectRec['end_date']),
404
- activity_type: str(projectRec['activity_type']),
406
+ activity_type: str(projectRec['action_type']) ?? str(projectRec['activity_type']),
405
407
  status: str(projectRec['status']),
406
408
  };
407
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';