@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
@@ -12,9 +12,40 @@ const minimalValid = {
12
12
  ],
13
13
  };
14
14
  describe('validateActivities', () => {
15
- it('ACT-001 — accepts valid notation field', () => {
15
+ it('ACT-001 — accepts notation: activities (deprecated)', () => {
16
16
  const r = validateActivities(minimalValid);
17
17
  expect(r.valid).toBe(true);
18
+ // deprecated notation emits a warning, not an error
19
+ expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(true);
20
+ });
21
+ it('ACT-001 — accepts notation: action (canonical)', () => {
22
+ const r = validateActivities({
23
+ notation: 'action',
24
+ actions: [
25
+ { id: 'ACTION-001', name: 'Start', duration: 3 },
26
+ { id: 'ACTION-002', name: 'End', duration: 2, predecessors: ['ACTION-001'] },
27
+ ],
28
+ });
29
+ expect(r.valid).toBe(true);
30
+ expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(false);
31
+ });
32
+ it('ACT-001 — accepts notation: action with deprecated activities: key (emits field warning)', () => {
33
+ const r = validateActivities({
34
+ notation: 'action',
35
+ activities: [{ id: 'ACTION-001', name: 'Foo', duration: 1 }],
36
+ });
37
+ expect(r.valid).toBe(true);
38
+ expect(r.warnings.some(w => w.code === 'DEPRECATED_FIELD')).toBe(true);
39
+ });
40
+ it('ACT-001 — normalises action_type to activity_type for downstream layout', () => {
41
+ const input = {
42
+ notation: 'action',
43
+ actions: [{ id: 'ACTION-001', name: 'Foo', duration: 1, action_type: 'project' }],
44
+ };
45
+ const r = validateActivities(input);
46
+ expect(r.valid).toBe(true);
47
+ const entry = input.actions[0];
48
+ expect(entry['activity_type']).toBe('project');
18
49
  });
19
50
  it('ACT-001 — rejects missing notation', () => {
20
51
  const r = validateActivities({ activities: [] });
@@ -7,19 +7,31 @@ export function validateActivities(input) {
7
7
  return { valid: false, errors: [{ code: 'ACT-001', message: 'Input must be an object' }], warnings };
8
8
  }
9
9
  const raw = input;
10
- // ACT-001: notation must equal "activities"
10
+ // ACT-001: notation must be "action" (canonical) or "activities" (deprecated)
11
11
  if (raw.notation === undefined) {
12
12
  errors.push({ code: 'ACT-001', message: 'notation field is required' });
13
13
  return { valid: false, errors, warnings };
14
14
  }
15
- if (raw.notation !== 'activities') {
16
- errors.push({ code: 'ACT-001', message: `notation must be "activities", got "${String(raw.notation)}"` });
15
+ if (raw.notation !== 'action' && raw.notation !== 'activities') {
16
+ errors.push({ code: 'ACT-001', message: `notation must be "action", got "${String(raw.notation)}"` });
17
17
  return { valid: false, errors, warnings };
18
18
  }
19
- if (!Array.isArray(raw.activities)) {
20
- errors.push({ code: 'SCHEMA_INVALID', message: 'activities must be an array', path: 'activities' });
19
+ if (raw.notation === 'activities') {
20
+ warnings.push({ code: 'DEPRECATED_NOTATION', message: 'notation: "activities" is deprecated — migrate to notation: "action"' });
21
+ }
22
+ // Accept "actions:" (canonical) or "activities:" (deprecated)
23
+ const rawArray = Array.isArray(raw.actions) ? raw.actions : (Array.isArray(raw.activities) ? raw.activities : undefined);
24
+ if (Array.isArray(raw.activities) && !Array.isArray(raw.actions)) {
25
+ warnings.push({ code: 'DEPRECATED_FIELD', message: 'Root key "activities:" is deprecated — rename to "actions:"' });
26
+ }
27
+ if (!rawArray) {
28
+ const canonKey = raw.notation === 'action' ? 'actions' : 'activities';
29
+ errors.push({ code: 'SCHEMA_INVALID', message: `${canonKey} must be an array`, path: canonKey });
21
30
  return { valid: false, errors, warnings };
22
31
  }
32
+ // Normalise onto raw.activities for the rest of this function (avoids touching downstream code)
33
+ if (!Array.isArray(raw.activities))
34
+ raw.activities = raw.actions;
23
35
  // ACT-014 / ACT-015: project.calendar validation (run before per-activity loop;
24
36
  // surfaces clearly even when activities also have issues).
25
37
  if (raw.project && typeof raw.project === 'object') {
@@ -85,6 +97,10 @@ export function validateActivities(input) {
85
97
  continue;
86
98
  }
87
99
  const act = a;
100
+ // Normalise action_type → activity_type (canonical rename; old name kept for compat)
101
+ if (act['action_type'] !== undefined && act['activity_type'] === undefined) {
102
+ act['activity_type'] = act['action_type'];
103
+ }
88
104
  // ACT-010: reject single-value forms
89
105
  if ('goal' in act) {
90
106
  errors.push({ code: 'ACT-010', message: `Use "goals: []" array form instead of "goal:" (activity at index ${i})`, path });
@@ -220,7 +220,7 @@ describe('resolveActivityCard', () => {
220
220
  expect(pc001.message).toContain('canon/elements/**');
221
221
  expect(pc001.message).toContain('canon/views/activities/**');
222
222
  // Must include actionable hint with expected file pattern (§7 PC-001)
223
- expect(pc001.message).toContain('notation: activity');
223
+ expect(pc001.message).toContain('notation: action');
224
224
  expect(pc001.message).toContain(`id: "${CARD.activity_card.project}"`);
225
225
  });
226
226
  it('resolves without PC-001 when project activity lives in a deep canon/elements subfolder (#344)', () => {
@@ -18,10 +18,32 @@ const VALID = {
18
18
  },
19
19
  };
20
20
  describe('validateActivityCard', () => {
21
- it('accepts a well-formed card', () => {
21
+ it('accepts a well-formed card (deprecated notation: activity-card)', () => {
22
22
  const r = validateActivityCard(VALID);
23
23
  expect(r.valid).toBe(true);
24
24
  expect(r.errors).toHaveLength(0);
25
+ expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(true);
26
+ });
27
+ it('accepts notation: action-card with action_card block (canonical)', () => {
28
+ const r = validateActivityCard({
29
+ notation: 'action-card',
30
+ action_card: {
31
+ id: 'ACTION_CARD-EU-PROGRAMME-1',
32
+ project: 'ACTION-EU-PROGRAMME-1',
33
+ },
34
+ });
35
+ expect(r.valid).toBe(true);
36
+ expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(false);
37
+ });
38
+ it('accepts action_card block with legacy ACTIVITY_CARD id prefix', () => {
39
+ const r = validateActivityCard({
40
+ notation: 'action-card',
41
+ action_card: {
42
+ id: 'ACTIVITY_CARD-EU-PROGRAMME-1',
43
+ project: 'ACTION-EU-PROGRAMME-1',
44
+ },
45
+ });
46
+ expect(r.valid).toBe(true);
25
47
  });
26
48
  it('rejects non-object input', () => {
27
49
  expect(validateActivityCard('x').valid).toBe(false);
@@ -81,9 +81,9 @@ function collectByNotation(docs, notation) {
81
81
  return out;
82
82
  }
83
83
  /**
84
- * GOAL ids reached by an *active* `activity_goal` relation originating at
85
- * `fromId`. A relation with a non-null `valid_to` has ended (the activity was
86
- * re-aimed) and is excluded — the card shows the currently-served goals.
84
+ * GOAL ids reached by an *active* `action_goal` (or legacy `activity_goal`)
85
+ * relation originating at `fromId`. A relation with a non-null `valid_to` has
86
+ * ended (the activity was re-aimed) and is excluded.
87
87
  */
88
88
  function activeActivityGoals(relations, fromId) {
89
89
  const out = [];
@@ -92,7 +92,8 @@ function activeActivityGoals(relations, fromId) {
92
92
  continue;
93
93
  if (str(doc['notation']) !== 'relation')
94
94
  continue;
95
- if (str(doc['type']) !== 'activity_goal')
95
+ const type = str(doc['type']);
96
+ if (type !== 'action_goal' && type !== 'activity_goal')
96
97
  continue;
97
98
  if (str(doc['from']) !== fromId)
98
99
  continue;
@@ -108,11 +109,8 @@ function activeActivityGoals(relations, fromId) {
108
109
  return out;
109
110
  }
110
111
  /**
111
- * STAKEHOLDER ids + roles reached by an *active* `activity_stakeholder`
112
- * relation originating at `fromId`. A relation with a non-null `valid_to` has
113
- * ended and is excluded, so the card shows the project's currently-engaged
114
- * stakeholders. The optional `role` field on the relation carries the
115
- * project-role (initiator | owner | sponsor | project_manager).
112
+ * STAKEHOLDER ids + roles reached by an *active* `action_stakeholder` (or
113
+ * legacy `activity_stakeholder`) relation originating at `fromId`.
116
114
  */
117
115
  function activeActivityStakeholders(relations, fromId) {
118
116
  const out = [];
@@ -121,7 +119,8 @@ function activeActivityStakeholders(relations, fromId) {
121
119
  continue;
122
120
  if (str(doc['notation']) !== 'relation')
123
121
  continue;
124
- if (str(doc['type']) !== 'activity_stakeholder')
122
+ const type = str(doc['type']);
123
+ if (type !== 'action_stakeholder' && type !== 'activity_stakeholder')
125
124
  continue;
126
125
  if (str(doc['from']) !== fromId)
127
126
  continue;
@@ -146,18 +145,20 @@ export function resolveActivityCard(doc, sources) {
146
145
  errors.push({ code: 'PC-001', message: 'activity_card.project is missing' });
147
146
  return { valid: false, errors, warnings };
148
147
  }
149
- const activities = collectByNotation(sources.elements, 'activity');
150
- const projectRec = activities.get(projectId);
151
- // PC-001 — project must resolve to an admitted ACTIVITY element after
152
- // exhausting the canonical resolution scope (§6.1): canon/elements/**
153
- // recursively first, then canon/views/activities/** as secondary fallback.
148
+ // Look up by canonical `notation: action` first, then legacy `notation: activity`.
149
+ const actionElements = collectByNotation(sources.elements, 'action');
150
+ const activityElements = collectByNotation(sources.elements, 'activity');
151
+ const allActionElements = new Map([...activityElements, ...actionElements]); // action wins on duplicate id
152
+ const projectRec = allActionElements.get(projectId);
153
+ // PC-001 — project must resolve to an admitted ACTION (or legacy ACTIVITY)
154
+ // element after exhausting the canonical resolution scope (§6.1).
154
155
  if (!projectRec) {
155
156
  errors.push({
156
157
  code: 'PC-001',
157
- message: `activity_card.project "${projectId}" not found after searching ` +
158
+ message: `action_card.project "${projectId}" not found after searching ` +
158
159
  `canon/elements/** and canon/views/activities/** — ` +
159
- `add a YAML file with notation: activity and id: "${projectId}" ` +
160
- `(e.g. canon/elements/05_implementation/activities/${projectId}.yaml)`,
160
+ `add a YAML file with notation: action and id: "${projectId}" ` +
161
+ `(e.g. canon/elements/05_implementation/actions/${projectId}.yaml)`,
161
162
  });
162
163
  return { valid: false, errors, warnings };
163
164
  }
@@ -328,14 +329,15 @@ export function resolveActivityCard(doc, sources) {
328
329
  return a.observed_at < b.observed_at ? -1 : a.observed_at > b.observed_at ? 1 : 0;
329
330
  });
330
331
  // ── Child activities — parent = project id ──────────────────────────────────
332
+ // Look in both `notation: action` and legacy `notation: activity` elements.
331
333
  const childActivities = [];
332
- for (const [id, rec] of activities) {
334
+ for (const [id, rec] of allActionElements) {
333
335
  if (str(rec['parent']) !== projectId)
334
336
  continue;
335
337
  childActivities.push({
336
338
  id,
337
339
  name: str(rec['name']) ?? id,
338
- activity_type: str(rec['activity_type']),
340
+ activity_type: str(rec['action_type']) ?? str(rec['activity_type']),
339
341
  start_date: str(rec['start_date']),
340
342
  end_date: str(rec['end_date']),
341
343
  owner: str(rec['owner']),
@@ -378,7 +380,7 @@ export function resolveActivityCard(doc, sources) {
378
380
  valid_to: validToOk ? validToRaw : undefined,
379
381
  start_date: str(projectRec['start_date']),
380
382
  end_date: str(projectRec['end_date']),
381
- activity_type: str(projectRec['activity_type']),
383
+ activity_type: str(projectRec['action_type']) ?? str(projectRec['activity_type']),
382
384
  status: str(projectRec['status']),
383
385
  };
384
386
  const resolved = {
@@ -21,8 +21,8 @@
21
21
  // HDR-002 wrong `notation` (CONTRACT.md §2)
22
22
  // PC-001 `project` missing or malformed (the document-local half; the
23
23
  // "does not resolve" half is emitted by the resolver)
24
- const ACTIVITY_CARD_ID_RE = /^ACTIVITY_CARD(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
25
- const ACTIVITY_ID_RE = /^ACTIVITY(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
24
+ const ACTIVITY_CARD_ID_RE = /^(ACTIVITY_CARD|ACTION_CARD)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
25
+ const ACTIVITY_ID_RE = /^(ACTIVITY|ACTION)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
26
26
  const MILESTONE_ID_RE = /^MILESTONE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
27
27
  const CHANGE_ID_RE = /^CHANGE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
28
28
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -40,36 +40,47 @@ export function validateActivityCard(input) {
40
40
  if (!('notation' in raw)) {
41
41
  errors.push({ code: 'HDR-001', message: 'notation field is required' });
42
42
  }
43
- else if (raw['notation'] !== 'activity-card') {
43
+ else if (raw['notation'] !== 'action-card' && raw['notation'] !== 'activity-card') {
44
44
  errors.push({
45
45
  code: 'HDR-002',
46
- message: `notation must be "activity-card", got "${String(raw['notation'])}"`,
46
+ message: `notation must be "action-card", got "${String(raw['notation'])}"`,
47
47
  });
48
48
  }
49
- // ── activity_card block ─────────────────────────────────────────────────────
50
- const block = raw['activity_card'];
49
+ else if (raw['notation'] === 'activity-card') {
50
+ warnings.push({ code: 'DEPRECATED_NOTATION', message: 'notation: "activity-card" is deprecated — migrate to notation: "action-card"' });
51
+ }
52
+ // ── action_card / activity_card block ───────────────────────────────────────
53
+ // "action_card:" is canonical; "activity_card:" is accepted for compat.
54
+ const rawBlock = raw['action_card'] ?? raw['activity_card'];
55
+ if (raw['activity_card'] !== undefined && raw['action_card'] === undefined) {
56
+ warnings.push({ code: 'DEPRECATED_FIELD', message: 'Root key "activity_card:" is deprecated — rename to "action_card:"' });
57
+ }
58
+ const block = rawBlock;
51
59
  if (!block || typeof block !== 'object' || Array.isArray(block)) {
52
- errors.push({ code: 'AC-001', message: 'Missing required root key: activity_card (object)' });
60
+ errors.push({ code: 'AC-001', message: 'Missing required root key: action_card (object)' });
53
61
  return { valid: false, errors, warnings };
54
62
  }
63
+ // Normalise onto activity_card for downstream (resolver reads doc.activity_card)
64
+ if (raw['activity_card'] === undefined)
65
+ raw['activity_card'] = rawBlock;
55
66
  const card = block;
56
67
  if (!isNonEmptyString(card['id'])) {
57
- errors.push({ code: 'AC-002', message: 'activity_card.id is required' });
68
+ errors.push({ code: 'AC-002', message: 'action_card.id is required' });
58
69
  }
59
70
  else if (!ACTIVITY_CARD_ID_RE.test(card['id'])) {
60
71
  errors.push({
61
72
  code: 'AC-002',
62
- message: `activity_card.id "${card['id']}" must match ACTIVITY_CARD-[<middle>-]<INTEGER>`,
73
+ message: `action_card.id "${card['id']}" must match ACTION_CARD-[<middle>-]<INTEGER> (or legacy ACTIVITY_CARD-…)`,
63
74
  });
64
75
  }
65
76
  // PC-001 (document-local half): project present + well-formed.
66
77
  if (!isNonEmptyString(card['project'])) {
67
- errors.push({ code: 'PC-001', message: 'activity_card.project is required (an ACTIVITY-… id)' });
78
+ errors.push({ code: 'PC-001', message: 'action_card.project is required (an ACTION-… id)' });
68
79
  }
69
80
  else if (!ACTIVITY_ID_RE.test(card['project'])) {
70
81
  errors.push({
71
82
  code: 'PC-001',
72
- message: `activity_card.project "${card['project']}" is malformed; must match ACTIVITY-[<middle>-]<INTEGER>`,
83
+ message: `action_card.project "${card['project']}" is malformed; must match ACTION-[<middle>-]<INTEGER> (or legacy ACTIVITY-…)`,
73
84
  });
74
85
  }
75
86
  if (card['description'] !== undefined && typeof card['description'] !== 'string') {
@@ -1,2 +1,6 @@
1
1
  export type { CapabilityType, CapabilityNode, CapabilityMapHeader, CapabilityMapFile, } from './types.js';
2
2
  export { validateCapabilityMap } from './validate.js';
3
+ export type { CapabilityTreeNode, CapabilityTreeEdge, CapabilityTreeLevelCounts, CapabilityTreeLayout, } from './layout-tree.js';
4
+ export { layoutCapabilityTree, TREE_NODE_WIDTH, TREE_NODE_HEIGHT, TREE_RANK_SEP, TREE_NODE_SEP } from './layout-tree.js';
5
+ export type { RenderCapabilityTreeOptions } from './render-capability-tree.js';
6
+ export { renderCapabilityTreeSvg } from './render-capability-tree.js';
@@ -1 +1,3 @@
1
1
  export { validateCapabilityMap } from './validate.js';
2
+ export { layoutCapabilityTree, TREE_NODE_WIDTH, TREE_NODE_HEIGHT, TREE_RANK_SEP, TREE_NODE_SEP } from './layout-tree.js';
3
+ export { renderCapabilityTreeSvg } from './render-capability-tree.js';
@@ -0,0 +1,42 @@
1
+ import type { CapabilityNode, CapabilityMapHeader } from './types.js';
2
+ export declare const TREE_NODE_WIDTH = 250;
3
+ export declare const TREE_NODE_HEIGHT = 64;
4
+ /** Gap between sibling columns (node width + rank sep = 330px, matching DSM). */
5
+ export declare const TREE_RANK_SEP = 80;
6
+ /** Vertical gap between sibling nodes. */
7
+ export declare const TREE_NODE_SEP = 24;
8
+ export interface CapabilityTreeNode {
9
+ id: string;
10
+ x: number;
11
+ y: number;
12
+ width: number;
13
+ height: number;
14
+ data: CapabilityNode;
15
+ depth: number;
16
+ hasChildren: boolean;
17
+ isCollapsed: boolean;
18
+ }
19
+ export interface CapabilityTreeEdge {
20
+ source: string;
21
+ target: string;
22
+ }
23
+ export interface CapabilityTreeLevelCounts {
24
+ /** Nodes at depth 0–2 (pink band). */
25
+ band0: number;
26
+ /** Nodes at depth 3–4 (yellow band). */
27
+ band1: number;
28
+ /** Nodes at depth 5+ (blue band). */
29
+ band2: number;
30
+ }
31
+ export interface CapabilityTreeLayout {
32
+ nodes: CapabilityTreeNode[];
33
+ edges: CapabilityTreeEdge[];
34
+ bounds: {
35
+ x: number;
36
+ y: number;
37
+ width: number;
38
+ height: number;
39
+ };
40
+ levelCounts: CapabilityTreeLevelCounts;
41
+ }
42
+ export declare function layoutCapabilityTree(map: CapabilityMapHeader, collapsedIds?: Set<string>): CapabilityTreeLayout;
@@ -0,0 +1,65 @@
1
+ export const TREE_NODE_WIDTH = 250;
2
+ export const TREE_NODE_HEIGHT = 64;
3
+ /** Gap between sibling columns (node width + rank sep = 330px, matching DSM). */
4
+ export const TREE_RANK_SEP = 80;
5
+ /** Vertical gap between sibling nodes. */
6
+ export const TREE_NODE_SEP = 24;
7
+ export function layoutCapabilityTree(map, collapsedIds = new Set()) {
8
+ const nodes = [];
9
+ const edges = [];
10
+ const colNextY = new Map();
11
+ function getNextY(col) {
12
+ return colNextY.get(col) ?? 0;
13
+ }
14
+ function advanceY(col, delta) {
15
+ colNextY.set(col, (colNextY.get(col) ?? 0) + delta);
16
+ }
17
+ function placeNode(node, depth) {
18
+ const isCollapsed = collapsedIds.has(node.id);
19
+ const hasChildren = Boolean(node.children?.length);
20
+ const visibleChildren = !isCollapsed && node.children?.length ? node.children : [];
21
+ const x = depth * (TREE_NODE_WIDTH + TREE_RANK_SEP);
22
+ if (visibleChildren.length === 0) {
23
+ const y = getNextY(depth);
24
+ advanceY(depth, TREE_NODE_HEIGHT + TREE_NODE_SEP);
25
+ nodes.push({ id: node.id, x, y, width: TREE_NODE_WIDTH, height: TREE_NODE_HEIGHT, data: node, depth, hasChildren, isCollapsed });
26
+ return { top: y, bottom: y + TREE_NODE_HEIGHT };
27
+ }
28
+ const childSpans = [];
29
+ for (const child of visibleChildren) {
30
+ childSpans.push(placeNode(child, depth + 1));
31
+ edges.push({ source: node.id, target: child.id });
32
+ }
33
+ const spanTop = Math.min(...childSpans.map(s => s.top));
34
+ const spanBottom = Math.max(...childSpans.map(s => s.bottom));
35
+ const parentY = spanTop + (spanBottom - spanTop) / 2 - TREE_NODE_HEIGHT / 2;
36
+ const curColY = getNextY(depth);
37
+ const finalY = Math.max(parentY, curColY);
38
+ advanceY(depth, finalY - curColY + TREE_NODE_HEIGHT + TREE_NODE_SEP);
39
+ nodes.push({ id: node.id, x, y: finalY, width: TREE_NODE_WIDTH, height: TREE_NODE_HEIGHT, data: node, depth, hasChildren, isCollapsed });
40
+ return { top: Math.min(finalY, spanTop), bottom: Math.max(finalY + TREE_NODE_HEIGHT, spanBottom) };
41
+ }
42
+ for (const root of map.capabilities) {
43
+ placeNode(root, 0);
44
+ for (const [col, y] of colNextY) {
45
+ colNextY.set(col, y + TREE_NODE_SEP);
46
+ }
47
+ }
48
+ if (nodes.length === 0) {
49
+ return { nodes: [], edges: [], bounds: { x: 0, y: 0, width: 0, height: 0 }, levelCounts: { band0: 0, band1: 0, band2: 0 } };
50
+ }
51
+ const minX = Math.min(...nodes.map(n => n.x));
52
+ const minY = Math.min(...nodes.map(n => n.y));
53
+ const maxX = Math.max(...nodes.map(n => n.x + n.width));
54
+ const maxY = Math.max(...nodes.map(n => n.y + n.height));
55
+ const levelCounts = { band0: 0, band1: 0, band2: 0 };
56
+ for (const n of nodes) {
57
+ if (n.depth <= 2)
58
+ levelCounts.band0++;
59
+ else if (n.depth <= 4)
60
+ levelCounts.band1++;
61
+ else
62
+ levelCounts.band2++;
63
+ }
64
+ return { nodes, edges, bounds: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, levelCounts };
65
+ }
@@ -0,0 +1,15 @@
1
+ import type { CapabilityMapHeader } from './types.js';
2
+ export interface RenderCapabilityTreeOptions {
3
+ collapsedIds?: Set<string>;
4
+ curvature?: number;
5
+ }
6
+ /**
7
+ * Renders a capability-map as a left-to-right SVG node tree with DSM-matching
8
+ * visual design: depth-banded fills, maturity badges, and +/− collapse buttons.
9
+ *
10
+ * The SVG uses CSS classes (`tree-level-N`, `tree-maturity-N`, `tree-collapse-btn`)
11
+ * that must be resolved by the host's stylesheet (injected via `diagramClassCss()`
12
+ * in themes.ts). Collapse buttons carry `data-node-id` / `data-collapsed` attributes
13
+ * for the webview click handler.
14
+ */
15
+ export declare function renderCapabilityTreeSvg(map: CapabilityMapHeader, opts?: RenderCapabilityTreeOptions): string;
@@ -0,0 +1,108 @@
1
+ import { layoutCapabilityTree, TREE_NODE_WIDTH, TREE_NODE_HEIGHT, TREE_NODE_SEP } from './layout-tree.js';
2
+ import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
3
+ import { escXml } from '../webview/render-util.js';
4
+ const PAD = 24;
5
+ const BTN_R = 8;
6
+ const LEGEND_H = 40;
7
+ const LABEL_MAX_CHARS = 28;
8
+ const LABEL_TRIM_CHARS = 26;
9
+ const BAND_LABELS = ['Levels 0–2', 'Levels 3–4', 'Levels 5+'];
10
+ function depthBand(depth) {
11
+ if (depth <= 2)
12
+ return 0;
13
+ if (depth <= 4)
14
+ return 1;
15
+ return 2;
16
+ }
17
+ function truncate(s, max, trim) {
18
+ return s.length > max ? s.slice(0, trim) + '…' : s;
19
+ }
20
+ /**
21
+ * Renders a capability-map as a left-to-right SVG node tree with DSM-matching
22
+ * visual design: depth-banded fills, maturity badges, and +/− collapse buttons.
23
+ *
24
+ * The SVG uses CSS classes (`tree-level-N`, `tree-maturity-N`, `tree-collapse-btn`)
25
+ * that must be resolved by the host's stylesheet (injected via `diagramClassCss()`
26
+ * in themes.ts). Collapse buttons carry `data-node-id` / `data-collapsed` attributes
27
+ * for the webview click handler.
28
+ */
29
+ export function renderCapabilityTreeSvg(map, opts = {}) {
30
+ const { collapsedIds = new Set(), curvature = DEFAULT_EDGE_CURVATURE } = opts;
31
+ const layout = layoutCapabilityTree(map, collapsedIds);
32
+ if (layout.nodes.length === 0) {
33
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
34
+ }
35
+ const ox = -layout.bounds.x + PAD;
36
+ const oy = -layout.bounds.y + PAD;
37
+ const svgW = layout.bounds.width + PAD * 2 + BTN_R;
38
+ const svgH = layout.bounds.height + PAD * 2 + LEGEND_H + TREE_NODE_SEP;
39
+ const nodeMap = new Map(layout.nodes.map(n => [n.id, n]));
40
+ const edgeSvg = layout.edges.map(e => {
41
+ const s = nodeMap.get(e.source);
42
+ const t = nodeMap.get(e.target);
43
+ if (!s || !t)
44
+ return '';
45
+ const sx = s.x + ox + s.width;
46
+ const sy = s.y + oy + s.height / 2;
47
+ const tx = t.x + ox;
48
+ const ty = t.y + oy + t.height / 2;
49
+ return `<path d="${horizontalCubicEdgePath(sx, sy, tx, ty, curvature)}" class="diagram-edge"/>`;
50
+ }).filter(Boolean).join('\n');
51
+ const nodeSvg = layout.nodes.map(n => {
52
+ const x = n.x + ox;
53
+ const y = n.y + oy;
54
+ const band = depthBand(n.depth);
55
+ const mat = Math.max(1, Math.min(5, n.data.current_maturity | 0));
56
+ const nameText = truncate(n.data.name, LABEL_MAX_CHARS, LABEL_TRIM_CHARS);
57
+ const nameTitle = n.data.name.length > LABEL_MAX_CHARS ? escXml(n.data.name) : '';
58
+ // Maturity badge: left side of node
59
+ const badgeW = 28;
60
+ const badgeH = 18;
61
+ const badgeX = x + 10;
62
+ const badgeY = y + (TREE_NODE_HEIGHT - badgeH) / 2;
63
+ // Text area: right of badge
64
+ const textX = badgeX + badgeW + 8;
65
+ const nameY = y + TREE_NODE_HEIGHT / 2 - 8;
66
+ const idY = y + TREE_NODE_HEIGHT / 2 + 9;
67
+ // Collapse button: centered on right edge of node
68
+ const btnCx = x + TREE_NODE_WIDTH;
69
+ const btnCy = y + TREE_NODE_HEIGHT / 2;
70
+ const collapseBtn = n.hasChildren
71
+ ? `<circle class="tree-collapse-btn" cx="${btnCx}" cy="${btnCy}" r="${BTN_R}" data-node-id="${escXml(n.id)}" data-collapsed="${n.isCollapsed}"/>
72
+ <text class="tree-collapse-lbl" x="${btnCx}" y="${btnCy}" text-anchor="middle">${n.isCollapsed ? '+' : '−'}</text>`
73
+ : '';
74
+ return `<g${nameTitle ? ` title="${nameTitle}"` : ''}>
75
+ <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"/>
76
+ <rect class="tree-maturity-${mat}" x="${badgeX}" y="${badgeY}" width="${badgeW}" height="${badgeH}" rx="4"/>
77
+ <text class="text-pill" x="${badgeX + badgeW / 2}" y="${badgeY + badgeH / 2}" text-anchor="middle" fill="white">L${mat}</text>
78
+ <text class="text-primary" x="${textX}" y="${nameY}" dominant-baseline="central">${escXml(nameText)}</text>
79
+ <text class="text-id" x="${textX}" y="${idY}" dominant-baseline="central">${escXml(n.id)}</text>
80
+ ${collapseBtn}
81
+ </g>`;
82
+ }).join('\n');
83
+ // Legend band: proportional coloured blocks per level-count band
84
+ const legendY = layout.bounds.height + PAD * 2 + TREE_NODE_SEP / 2;
85
+ const legendW = svgW - PAD * 2;
86
+ const totalNodes = layout.nodes.length || 1;
87
+ const bandCounts = [layout.levelCounts.band0, layout.levelCounts.band1, layout.levelCounts.band2];
88
+ let legendX = PAD;
89
+ const legendSvg = bandCounts.map((count, i) => {
90
+ const bw = Math.max(1, Math.round(legendW * count / totalNodes));
91
+ const bx = legendX;
92
+ legendX += bw;
93
+ if (count === 0)
94
+ return '';
95
+ 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"/>
96
+ <text class="text-id" x="${bx + bw / 2}" y="${legendY + 12}" text-anchor="middle" dominant-baseline="central">${escXml(BAND_LABELS[i])}</text>`;
97
+ }).filter(Boolean).join('\n');
98
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">
99
+ <defs>
100
+ <marker id="cap-tree-arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
101
+ <path d="M0,0 L0,6 L8,3 z" class="arrow-fill"/>
102
+ </marker>
103
+ </defs>
104
+ ${edgeSvg}
105
+ ${nodeSvg}
106
+ ${legendSvg}
107
+ </svg>`;
108
+ }
@@ -22,5 +22,7 @@ export interface CapabilityMapHeader {
22
22
  export interface CapabilityMapFile {
23
23
  notation: string;
24
24
  spec_version?: string;
25
+ /** Render mode for Studio previews. Defaults to 'cards'. */
26
+ view?: 'cards' | 'tree';
25
27
  capability_map: CapabilityMapHeader;
26
28
  }
@@ -47,11 +47,11 @@ describe('validateFGCADoc', () => {
47
47
  expect(result.valid).toBe(false);
48
48
  expect(result.errors.some(e => e.path === 'changes')).toBe(true);
49
49
  });
50
- it('errors when activities array is missing', () => {
50
+ it('errors when actions array is missing', () => {
51
51
  const { activities: _, ...without } = VALID_DOC;
52
52
  const result = validateFGCADoc({ ...without, notation: 'fgca' });
53
53
  expect(result.valid).toBe(false);
54
- expect(result.errors.some(e => e.path === 'activities')).toBe(true);
54
+ expect(result.errors.some(e => e.path === 'actions')).toBe(true);
55
55
  });
56
56
  it('errors on empty factor name', () => {
57
57
  const result = validateFGCADoc({ ...VALID_DOC, factors: [{ id: 1, name: '' }] });
@@ -91,7 +91,11 @@ export function parseCanonicalFGCA(input) {
91
91
  const factorsRaw = asObjectArray(raw['factors']);
92
92
  const goalsRaw = asObjectArray(raw['goals']);
93
93
  const changesRaw = asObjectArray(raw['changes']);
94
- const activitiesRaw = asObjectArray(raw['activities']);
94
+ // Accept canonical `actions:` or deprecated `activities:`; warn on the latter.
95
+ const activitiesRaw = asObjectArray(raw['actions']) ?? asObjectArray(raw['activities']);
96
+ if ('activities' in raw && !('actions' in raw)) {
97
+ warnings.push({ code: 'FGCA-001', message: '"activities" key is deprecated — rename to "actions"' });
98
+ }
95
99
  if (factorsRaw === null)
96
100
  errors.push({ code: 'FGCA-004', message: 'factors must be an array', path: 'factors' });
97
101
  if (goalsRaw === null)
@@ -99,7 +103,7 @@ export function parseCanonicalFGCA(input) {
99
103
  if (changesRaw === null)
100
104
  errors.push({ code: 'FGCA-004', message: 'changes must be an array', path: 'changes' });
101
105
  if (activitiesRaw === null)
102
- errors.push({ code: 'FGCA-004', message: 'activities must be an array', path: 'activities' });
106
+ errors.push({ code: 'FGCA-004', message: 'actions must be an array', path: 'actions' });
103
107
  if (errors.length > 0)
104
108
  return { valid: false, errors, warnings };
105
109
  // From here we know all four arrays exist.
@@ -237,11 +241,13 @@ export function parseCanonicalFGCA(input) {
237
241
  };
238
242
  });
239
243
  const internalActivities = activities.map((el, i) => {
240
- const goalRefs = checkRefArray(el, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `activities[${i}]`);
244
+ const goalRefs = checkRefArray(el, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `actions[${i}]`);
245
+ const typeVal = typeof el['type'] === 'string' ? el['type'] : undefined;
241
246
  return {
242
247
  id: intId(String(el['id'])),
243
248
  name: String(el['name'] ?? ''),
244
249
  goal_id: goalRefs.length > 0 ? intId(goalRefs[0]) : null,
250
+ ...(typeVal !== undefined ? { type: typeVal } : {}),
245
251
  };
246
252
  });
247
253
  // FACTOR.references_constraint grammar check (FGCA-015).
@@ -22,6 +22,8 @@ export interface ActivityItem {
22
22
  name: string;
23
23
  goal_id?: number | string | null;
24
24
  activity_type_id?: number;
25
+ /** Canonical values: initiative | programme | project | work_package. Unknown values pass through without error. */
26
+ type?: string;
25
27
  }
26
28
  export interface ActivityTypeItem {
27
29
  id: number | string;