@transitrix/diagrams 1.6.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/dist/activities/layout.js +7 -7
  2. package/dist/activities/types.d.ts +4 -0
  3. package/dist/capability-map/dsm-schema.d.ts +1 -1
  4. package/dist/capability-map/dsm-schema.js +1 -1
  5. package/dist/capability-map/layout-tree.d.ts +5 -1
  6. package/dist/capability-map/layout-tree.js +11 -9
  7. package/dist/capability-map/render-capability-tree.d.ts +2 -9
  8. package/dist/capability-map/render-capability-tree.js +16 -28
  9. package/dist/codex/__tests__/example.test.d.ts +1 -0
  10. package/dist/codex/__tests__/example.test.js +33 -0
  11. package/dist/codex/__tests__/validate.test.d.ts +1 -0
  12. package/dist/codex/__tests__/validate.test.js +68 -0
  13. package/dist/codex/index.d.ts +4 -0
  14. package/dist/codex/index.js +2 -0
  15. package/dist/codex/types.d.ts +7 -0
  16. package/dist/codex/types.js +8 -0
  17. package/dist/codex/validate.d.ts +11 -0
  18. package/dist/codex/validate.js +103 -0
  19. package/dist/compliance/__tests__/canon-catalog.test.d.ts +1 -0
  20. package/dist/compliance/__tests__/canon-catalog.test.js +60 -0
  21. package/dist/compliance/__tests__/view-resolution.test.d.ts +1 -0
  22. package/dist/compliance/__tests__/view-resolution.test.js +59 -0
  23. package/dist/compliance/canon-catalog.d.ts +19 -0
  24. package/dist/compliance/canon-catalog.js +59 -0
  25. package/dist/compliance/coverage-metric.d.ts +12 -0
  26. package/dist/compliance/coverage-metric.js +35 -0
  27. package/dist/compliance/impact.d.ts +7 -1
  28. package/dist/compliance/impact.js +53 -0
  29. package/dist/compliance/index.d.ts +6 -4
  30. package/dist/compliance/index.js +3 -2
  31. package/dist/constraint/__tests__/example.test.d.ts +1 -0
  32. package/dist/constraint/__tests__/example.test.js +14 -0
  33. package/dist/constraint/__tests__/validate.test.d.ts +1 -0
  34. package/dist/constraint/__tests__/validate.test.js +58 -0
  35. package/dist/constraint/index.d.ts +4 -0
  36. package/dist/constraint/index.js +2 -0
  37. package/dist/constraint/types.d.ts +4 -0
  38. package/dist/constraint/types.js +2 -0
  39. package/dist/constraint/validate.d.ts +7 -0
  40. package/dist/constraint/validate.js +115 -0
  41. package/dist/fgca/__tests__/parse-canonical.test.js +31 -0
  42. package/dist/fgca/parse-canonical.d.ts +1 -1
  43. package/dist/fgca/parse-canonical.js +18 -4
  44. package/dist/fgca/preview-layout.d.ts +8 -4
  45. package/dist/fgca/preview-layout.js +13 -13
  46. package/dist/goals/GoalNode.d.ts +2 -1
  47. package/dist/goals/GoalNode.js +7 -2
  48. package/dist/goals/GoalTreeView.d.ts +3 -1
  49. package/dist/goals/GoalTreeView.js +31 -14
  50. package/dist/goals/__tests__/GoalTreeView.test.js +41 -0
  51. package/dist/goals/__tests__/mutations.test.js +39 -0
  52. package/dist/goals/__tests__/parse-canonical.test.js +2 -0
  53. package/dist/goals/layout.js +2 -1
  54. package/dist/goals/mutations.js +6 -6
  55. package/dist/goals/parse-canonical.d.ts +5 -0
  56. package/dist/goals/parse-canonical.js +5 -0
  57. package/dist/goals/types.d.ts +2 -0
  58. package/dist/node-size-presets.d.ts +50 -0
  59. package/dist/node-size-presets.js +103 -0
  60. package/dist/process-blueprint/layout.js +4 -47
  61. package/dist/repo-validate/types.js +2 -3
  62. package/dist/webview/__tests__/entity-text-layout.test.d.ts +1 -0
  63. package/dist/webview/__tests__/entity-text-layout.test.js +43 -0
  64. package/dist/webview/__tests__/entry.test.js +3 -3
  65. package/dist/webview/__tests__/render-blocks.test.js +1 -7
  66. package/dist/webview/__tests__/render-goals.test.js +17 -3
  67. package/dist/webview/entity-text-layout.d.ts +95 -0
  68. package/dist/webview/entity-text-layout.js +279 -0
  69. package/dist/webview/entry.js +7 -3
  70. package/dist/webview/notation-style.d.ts +1 -1
  71. package/dist/webview/notation-style.js +1 -1
  72. package/dist/webview/render-activities.d.ts +3 -1
  73. package/dist/webview/render-activities.js +29 -32
  74. package/dist/webview/render-blocks.d.ts +4 -1
  75. package/dist/webview/render-blocks.js +27 -108
  76. package/dist/webview/render-fgca.d.ts +15 -8
  77. package/dist/webview/render-fgca.js +41 -44
  78. package/dist/webview/render-goals.d.ts +2 -0
  79. package/dist/webview/render-goals.js +23 -37
  80. package/dist/webview/render-process-blueprint.d.ts +4 -10
  81. package/dist/webview/render-process-blueprint.js +18 -64
  82. package/package.json +1 -1
  83. package/src/activities/layout.ts +12 -7
  84. package/src/activities/types.ts +4 -0
  85. package/src/capability-map/dsm-schema.ts +1 -1
  86. package/src/capability-map/index.ts +1 -1
  87. package/src/capability-map/layout-tree.ts +16 -8
  88. package/src/capability-map/render-capability-tree.ts +18 -30
  89. package/src/codex/__tests__/example.test.ts +40 -0
  90. package/src/codex/__tests__/validate.test.ts +84 -0
  91. package/src/codex/index.ts +4 -0
  92. package/src/codex/types.ts +13 -0
  93. package/src/codex/validate.ts +125 -0
  94. package/src/compliance/__tests__/canon-catalog.test.ts +68 -0
  95. package/src/compliance/__tests__/view-resolution.test.ts +80 -0
  96. package/src/compliance/canon-catalog.ts +76 -0
  97. package/src/compliance/coverage-metric.ts +50 -0
  98. package/src/compliance/impact.ts +67 -1
  99. package/src/compliance/index.ts +10 -1
  100. package/src/constraint/__tests__/example.test.ts +19 -0
  101. package/src/constraint/__tests__/validate.test.ts +68 -0
  102. package/src/constraint/index.ts +4 -0
  103. package/src/constraint/types.ts +5 -0
  104. package/src/constraint/validate.ts +131 -0
  105. package/src/fgca/__tests__/parse-canonical.test.ts +34 -0
  106. package/src/fgca/parse-canonical.ts +17 -4
  107. package/src/fgca/preview-layout.ts +21 -15
  108. package/src/goals/GoalNode.tsx +8 -1
  109. package/src/goals/GoalTreeView.tsx +61 -27
  110. package/src/goals/__tests__/GoalTreeView.test.tsx +48 -0
  111. package/src/goals/__tests__/mutations.test.ts +42 -0
  112. package/src/goals/__tests__/parse-canonical.test.ts +2 -0
  113. package/src/goals/layout.ts +2 -1
  114. package/src/goals/mutations.ts +6 -6
  115. package/src/goals/parse-canonical.ts +6 -0
  116. package/src/goals/types.ts +2 -0
  117. package/src/node-size-presets.ts +157 -0
  118. package/src/process-blueprint/layout.ts +4 -48
  119. package/src/repo-validate/types.ts +2 -3
  120. package/src/webview/__tests__/entity-text-layout.test.ts +52 -0
  121. package/src/webview/__tests__/entry.test.ts +3 -3
  122. package/src/webview/__tests__/render-blocks.test.ts +1 -9
  123. package/src/webview/__tests__/render-goals.test.ts +17 -3
  124. package/src/webview/entity-text-layout.ts +355 -0
  125. package/src/webview/entry.ts +8 -4
  126. package/src/webview/notation-style.ts +1 -1
  127. package/src/webview/render-activities.ts +38 -27
  128. package/src/webview/render-blocks.ts +35 -118
  129. package/src/webview/render-fgca.ts +59 -44
  130. package/src/webview/render-goals.ts +24 -35
  131. package/src/webview/render-process-blueprint.ts +30 -65
@@ -0,0 +1,131 @@
1
+ // CONSTRAINT validator — methodology ELEMENT_PRIMITIVES.md §7.13 (sibling to
2
+ // 15-requirement.md). Implements CONST-001..005 for the compliance suite (#518 C4).
3
+
4
+ import type { ValidationError, ValidationWarning, ValidationResult } from '../validation-types.js';
5
+ import { typeOfId, isCanonicalIdOfType, type CanonCatalog } from '../typed-id.js';
6
+ import { CONSTRAINT_STATUSES, CONSTRAINT_SEVERITIES } from './types.js';
7
+
8
+ export interface ConstraintValidateOptions {
9
+ /** When provided, CONST-004/005 enforce artefact existence for cross-refs. */
10
+ catalog?: CanonCatalog;
11
+ }
12
+
13
+ const REQUIRED_FIELDS = [
14
+ 'notation',
15
+ 'name',
16
+ 'statement',
17
+ 'status',
18
+ 'zone',
19
+ 'admitted_at',
20
+ 'admitted_by',
21
+ 'gate_checks',
22
+ 'valid_from',
23
+ ] as const;
24
+
25
+ export function validateConstraint(
26
+ input: unknown,
27
+ options: ConstraintValidateOptions = {},
28
+ ): ValidationResult {
29
+ const errors: ValidationError[] = [];
30
+ const warnings: ValidationWarning[] = [];
31
+
32
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
33
+ errors.push({ code: 'CONST-001', message: 'Constraint must be a YAML mapping.' });
34
+ return { valid: false, errors, warnings };
35
+ }
36
+ const c = input as Record<string, unknown>;
37
+
38
+ if (!isCanonicalIdOfType(c.id, 'CONSTRAINT')) {
39
+ errors.push({
40
+ code: 'CONST-001',
41
+ message: `id "${String(c.id)}" must match CONSTRAINT-[<middle>-]<INTEGER>.`,
42
+ path: 'id',
43
+ });
44
+ }
45
+ if (c.notation !== 'constraint') {
46
+ errors.push({ code: 'CONST-001', message: 'notation must be the fixed value "constraint".', path: 'notation' });
47
+ }
48
+ for (const f of REQUIRED_FIELDS) {
49
+ if (f === 'notation') continue;
50
+ if (f === 'gate_checks') {
51
+ if (c.gate_checks === null || typeof c.gate_checks !== 'object') {
52
+ errors.push({ code: 'CONST-001', message: 'gate_checks is required and must be a mapping.', path: 'gate_checks' });
53
+ }
54
+ continue;
55
+ }
56
+ const v = c[f];
57
+ if (typeof v !== 'string' || v.trim() === '') {
58
+ errors.push({ code: 'CONST-001', message: `${f} is required.`, path: f });
59
+ }
60
+ }
61
+ if (c.zone !== 'canon') {
62
+ errors.push({ code: 'CONST-001', message: 'zone must be "canon" for a CONSTRAINT.', path: 'zone' });
63
+ }
64
+ if (!('valid_to' in c) || !(typeof c.valid_to === 'string' || c.valid_to === null)) {
65
+ errors.push({ code: 'CONST-001', message: 'valid_to is required (an ISO date string or null).', path: 'valid_to' });
66
+ }
67
+
68
+ const status = typeof c.status === 'string' ? c.status.trim() : '';
69
+ if (status && !(CONSTRAINT_STATUSES as readonly string[]).includes(status)) {
70
+ errors.push({
71
+ code: 'CONST-002',
72
+ message: `status must be one of ${CONSTRAINT_STATUSES.join(', ')}.`,
73
+ path: 'status',
74
+ });
75
+ }
76
+
77
+ if (c.severity !== undefined) {
78
+ const severity = typeof c.severity === 'string' ? c.severity.trim() : '';
79
+ if (!severity || !(CONSTRAINT_SEVERITIES as readonly string[]).includes(severity)) {
80
+ errors.push({
81
+ code: 'CONST-003',
82
+ message: `severity must be one of ${CONSTRAINT_SEVERITIES.join(', ')}.`,
83
+ path: 'severity',
84
+ });
85
+ }
86
+ }
87
+
88
+ if (c.applies_to !== undefined) {
89
+ if (!Array.isArray(c.applies_to)) {
90
+ errors.push({ code: 'CONST-001', message: 'applies_to must be a list of typed IDs.', path: 'applies_to' });
91
+ } else {
92
+ c.applies_to.forEach((ref, i) => {
93
+ const type = typeOfId(ref);
94
+ if (!type) {
95
+ errors.push({
96
+ code: 'CONST-004',
97
+ message: `applies_to[${i}] "${String(ref)}" is not a resolvable typed ID.`,
98
+ path: `applies_to[${i}]`,
99
+ });
100
+ return;
101
+ }
102
+ if (options.catalog && options.catalog.typeOf(ref as string) === undefined) {
103
+ errors.push({
104
+ code: 'CONST-004',
105
+ message: `applies_to[${i}] "${String(ref)}" does not resolve to an admitted artefact.`,
106
+ path: `applies_to[${i}]`,
107
+ });
108
+ }
109
+ });
110
+ }
111
+ }
112
+
113
+ if (c.owner_role !== undefined) {
114
+ const role = c.owner_role;
115
+ if (typeof role !== 'string' || !isCanonicalIdOfType(role, 'ROLE')) {
116
+ errors.push({
117
+ code: 'CONST-005',
118
+ message: `owner_role "${String(role)}" must be a typed ROLE id.`,
119
+ path: 'owner_role',
120
+ });
121
+ } else if (options.catalog && options.catalog.typeOf(role) === undefined) {
122
+ errors.push({
123
+ code: 'CONST-005',
124
+ message: `owner_role "${role}" does not resolve to an admitted artefact.`,
125
+ path: 'owner_role',
126
+ });
127
+ }
128
+ }
129
+
130
+ return { valid: errors.length === 0, errors, warnings };
131
+ }
@@ -35,6 +35,31 @@ describe('parseCanonicalFGCA', () => {
35
35
  expect(r.parsed?.activities).toHaveLength(1);
36
36
  });
37
37
 
38
+ it('accepts ACTION-* ids (methodology 1.0) alongside legacy ACTIVITY-*', () => {
39
+ const r = parseCanonicalFGCA({
40
+ ...VALID,
41
+ actions: [
42
+ { id: 'ACTION-CRM-EU-1', name: 'CRM rollout', changes: ['CHANGE-1'] },
43
+ { id: 'ACTIVITY-2', name: 'Legacy workstream', changes: ['CHANGE-1'] },
44
+ ],
45
+ });
46
+ expect(r.valid, JSON.stringify(r.errors)).toBe(true);
47
+ expect(r.parsed?.activities.map((a) => a.id)).toEqual(['ACTION-CRM-EU-1', 'ACTIVITY-2']);
48
+ });
49
+
50
+ it('defaults changes to [] when view_config.layers.changes is off (DGA-in-dgca)', () => {
51
+ const r = parseCanonicalFGCA({
52
+ ...VALID,
53
+ changes: undefined,
54
+ view_config: { layers: { changes: 'off' } },
55
+ actions: [
56
+ { id: 'ACTION-DISCOVERY-1', name: 'Gap assessment', goals: ['GOAL-1'] },
57
+ ],
58
+ });
59
+ expect(r.valid, JSON.stringify(r.errors)).toBe(true);
60
+ expect(r.parsed?.changes).toEqual([]);
61
+ });
62
+
38
63
  it('FGCA-001: rejects non-object input', () => {
39
64
  expect(parseCanonicalFGCA(null).errors[0].code).toBe('FGCA-001');
40
65
  expect(parseCanonicalFGCA('string').errors[0].code).toBe('FGCA-001');
@@ -198,6 +223,15 @@ describe('parseCanonicalFGA', () => {
198
223
  expect(r.parsed?.activities).toHaveLength(1);
199
224
  });
200
225
 
226
+ it('accepts ACTION-* ids in FGA mode', () => {
227
+ const r = parseCanonicalFGA({
228
+ ...VALID_FGA,
229
+ actions: [{ id: 'ACTION-GDPR-DSR-WORKFLOW-1', name: 'DSR workflow', goals: ['GOAL-1'] }],
230
+ });
231
+ expect(r.valid, JSON.stringify(r.errors)).toBe(true);
232
+ expect(r.parsed?.activities[0].id).toBe('ACTION-GDPR-DSR-WORKFLOW-1');
233
+ });
234
+
201
235
  it('populates activity.goal_id from activity.goals[] (the FGA edge-driving field)', () => {
202
236
  // The "FGA nodes render, no edges" bug: activities had no resolvable
203
237
  // goal_id, so the renderer drew no goal → activity edges.
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * - Flat top-level arrays, no `fgca:` wrapper.
8
8
  * - Typed string IDs per `IDS_AND_REFERENCES.md` (`FACTOR-1`,
9
- * `GOAL-RET-1`, `CHANGE-1`, `ACTIVITY-ONBOARD-1`).
9
+ * `GOAL-RET-1`, `CHANGE-1`, `ACTION-ONBOARD-1` (legacy `ACTIVITY-*` accepted).
10
10
  * - Plural canonical-direction cross-references: `goal.factors`,
11
11
  * `change.goals`, `activity.changes`, `activity.goals`.
12
12
  * - Per-notation validation codes `FGCA-001 … FGCA-015`.
@@ -50,7 +50,8 @@ const DRIVER_ID_RE = /^DRIVER(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
50
50
  const FACTOR_OR_DRIVER_ID_RE = /^(FACTOR|DRIVER)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
51
51
  const GOAL_ID_RE = /^GOAL(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
52
52
  const CHANGE_ID_RE = /^CHANGE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
53
- const ACTIVITY_ID_RE = /^ACTIVITY(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
53
+ // ACTION is canonical since methodology 1.0; ACTIVITY accepted for legacy docs.
54
+ const ACTION_OR_ACTIVITY_ID_RE = /^(ACTION|ACTIVITY)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
54
55
  const FGCA_DOC_ID_RE = /^(FGCA|DGCA)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
55
56
  const CONSTRAINT_ID_RE = /^CONSTRAINT(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
56
57
 
@@ -63,6 +64,15 @@ function asObjectArray(v: unknown): Array<Record<string, unknown>> | null {
63
64
  return v.map((el) => (el && typeof el === 'object' ? (el as Record<string, unknown>) : null) as Record<string, unknown>);
64
65
  }
65
66
 
67
+ /** DGA mode (`view_config.layers.changes: off`) may omit the changes layer. */
68
+ function isChangesLayerOff(raw: Record<string, unknown>): boolean {
69
+ const vc = raw['view_config'];
70
+ if (!vc || typeof vc !== 'object') return false;
71
+ const layers = (vc as Record<string, unknown>)['layers'];
72
+ if (!layers || typeof layers !== 'object') return false;
73
+ return (layers as Record<string, unknown>)['changes'] === 'off';
74
+ }
75
+
66
76
 
67
77
  /**
68
78
  * Validate canonical FGCA YAML and, on success, return an internal-form
@@ -114,7 +124,10 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
114
124
 
115
125
  const factorsRaw = asObjectArray(raw['factors']);
116
126
  const goalsRaw = asObjectArray(raw['goals']);
117
- const changesRaw = asObjectArray(raw['changes']);
127
+ let changesRaw = asObjectArray(raw['changes']);
128
+ if (changesRaw === null && raw['changes'] === undefined && isChangesLayerOff(raw)) {
129
+ changesRaw = [];
130
+ }
118
131
  const activitiesRaw = asObjectArray(raw['actions']);
119
132
  if (activitiesRaw === null && 'activities' in raw) {
120
133
  errors.push({ code: 'FGCA-004', message: '"activities" key is not accepted — rename to "actions"', path: 'actions' });
@@ -177,7 +190,7 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
177
190
  factors.forEach((el, i) => checkLayerElement(el, `factors[${i}]`, FACTOR_OR_DRIVER_ID_RE, factorIds));
178
191
  goals.forEach((el, i) => checkLayerElement(el, `goals[${i}]`, GOAL_ID_RE, goalIds));
179
192
  changes.forEach((el, i) => checkLayerElement(el, `changes[${i}]`, CHANGE_ID_RE, changeIds));
180
- activities.forEach((el, i) => checkLayerElement(el, `activities[${i}]`, ACTIVITY_ID_RE, activityIds));
193
+ activities.forEach((el, i) => checkLayerElement(el, `activities[${i}]`, ACTION_OR_ACTIVITY_ID_RE, activityIds));
181
194
 
182
195
  if (errors.length > 0) return { valid: false, errors, warnings };
183
196
 
@@ -1,6 +1,6 @@
1
- // Pure column-layout geometry for the static FGCA / FGA previews.
1
+ // Pure column-layout geometry for the static DGCA / DGA previews.
2
2
  //
3
- // This is the layout the Studio extension's `fgca-preview.ts` renders to SVG
3
+ // This is the layout the Studio extension's `dgca-preview.ts` renders to SVG
4
4
  // (NOT the ReactFlow `buildFGCALayout` in `./layout.ts`, which targets the
5
5
  // interactive web UI). It lives here — rather than inline in the extension —
6
6
  // so the gap geometry is unit-testable: the extension has no test harness.
@@ -33,7 +33,7 @@ export interface FGCAPreviewActivity {
33
33
  goal_id?: number | string | null;
34
34
  }
35
35
 
36
- /** Structural input the preview layout needs — a subset of the parsed FGCA/FGA doc. */
36
+ /** Structural input the preview layout needs — a subset of the parsed DGCA/DGA doc. */
37
37
  export interface FGCAPreviewDoc {
38
38
  factors: FGCAPreviewFactor[];
39
39
  goals: FGCAPreviewGoal[];
@@ -42,7 +42,7 @@ export interface FGCAPreviewDoc {
42
42
  }
43
43
 
44
44
  export interface FGCAPreviewLayoutOptions {
45
- /** FGA hides the Changes column and links Goal → Activity directly. */
45
+ /** DGA hides the Changes column and links Goal → Activity directly. */
46
46
  hideChanges?: boolean;
47
47
  /** Horizontal gap (px) between columns. Default matches the historical hardcoded value. */
48
48
  colGap?: number;
@@ -50,12 +50,16 @@ export interface FGCAPreviewLayoutOptions {
50
50
  rowGap?: number;
51
51
  /** Trim to a level cap or a single root goal (vkgeorgia/strategy#77). Defaults to 'all'. */
52
52
  scope?: Scope;
53
+ /** Entity node width (px). Default {@link FGCA_NODE_W}. */
54
+ nodeWidth?: number;
55
+ /** Entity node height (px). Default {@link FGCA_NODE_H}. */
56
+ nodeHeight?: number;
53
57
  }
54
58
 
55
59
  /**
56
- * Trims an FGCA/FGA doc to a scope (vkgeorgia/strategy#77).
60
+ * Trims a DGCA/DGA doc to a scope.
57
61
  *
58
- * FGCA/FGA goals are flat (no parent_id), so:
62
+ * DGCA/DGA goals are flat (no parent_id), so:
59
63
  * - 'level' → goals with `(level ?? 0) <= maxLevel`.
60
64
  * - 'root' → the single goal whose id matches `rootGoalId` (empty when absent).
61
65
  *
@@ -119,8 +123,8 @@ export interface FGCAPreviewLayout {
119
123
  height: number;
120
124
  }
121
125
 
122
- // Fixed node + frame geometry. Only the inter-node gaps are user-configurable
123
- // (vkgeorgia/strategy#75); node size and padding stay constant.
126
+ // Default node + frame geometry. Inter-node gaps are user-configurable via
127
+ // Inter-node gaps are user-configurable via spacing settings; node size uses presets.
124
128
  export const FGCA_NODE_W = 220;
125
129
  export const FGCA_NODE_H = 72;
126
130
  export const FGCA_HEADER_H = 32;
@@ -137,12 +141,14 @@ export function layoutFGCAPreview(
137
141
  colGap = FGCA_DEFAULT_COL_GAP,
138
142
  rowGap = FGCA_DEFAULT_ROW_GAP,
139
143
  scope = { mode: 'all' },
144
+ nodeWidth = FGCA_NODE_W,
145
+ nodeHeight = FGCA_NODE_H,
140
146
  } = options;
141
147
 
142
148
  // Trim to scope first; everything below lays out the visible subset only.
143
149
  const doc = selectScopedFGCA(inputDoc, scope);
144
150
 
145
- const colStride = FGCA_NODE_W + colGap;
151
+ const colStride = nodeWidth + colGap;
146
152
  const cols: FGCAPreviewColumn[] = hideChanges
147
153
  ? ['driver', 'goal', 'activity']
148
154
  : ['driver', 'goal', 'change', 'activity'];
@@ -206,7 +212,7 @@ export function layoutFGCAPreview(
206
212
  const nodeMap = new Map<string, FGCAPreviewNode>();
207
213
  const nodes: FGCAPreviewNode[] = [];
208
214
  const columns: FGCAPreviewColumnPos[] = [];
209
- const yCenters = new Map<string, number>(); // nodeId → y + FGCA_NODE_H / 2
215
+ const yCenters = new Map<string, number>(); // nodeId → y + nodeHeight / 2
210
216
 
211
217
  for (let ci = 0; ci < cols.length; ci++) {
212
218
  const col = cols[ci];
@@ -218,8 +224,8 @@ export function layoutFGCAPreview(
218
224
  const node: FGCAPreviewNode = { id: item.id, x, y, label: item.label, col };
219
225
  nodes.push(node);
220
226
  nodeMap.set(item.id, node);
221
- yCenters.set(item.id, y + FGCA_NODE_H / 2);
222
- y += FGCA_NODE_H + rowGap;
227
+ yCenters.set(item.id, y + nodeHeight / 2);
228
+ y += nodeHeight + rowGap;
223
229
  }
224
230
  }
225
231
 
@@ -228,7 +234,7 @@ export function layoutFGCAPreview(
228
234
  const s = nodeMap.get(sourceId);
229
235
  const t = nodeMap.get(targetId);
230
236
  if (!s || !t) return;
231
- edges.push({ sx: s.x + FGCA_NODE_W, sy: s.y + FGCA_NODE_H / 2, tx: t.x, ty: t.y + FGCA_NODE_H / 2 });
237
+ edges.push({ sx: s.x + nodeWidth, sy: s.y + nodeHeight / 2, tx: t.x, ty: t.y + nodeHeight / 2 });
232
238
  }
233
239
 
234
240
  for (const g of doc.goals) {
@@ -259,8 +265,8 @@ export function layoutFGCAPreview(
259
265
  }
260
266
 
261
267
  const maxNodeBottom = nodes.reduce(
262
- (m, n) => Math.max(m, n.y + FGCA_NODE_H),
263
- FGCA_PAD + FGCA_HEADER_H + rowGap + FGCA_NODE_H,
268
+ (m, n) => Math.max(m, n.y + nodeHeight),
269
+ FGCA_PAD + FGCA_HEADER_H + rowGap + nodeHeight,
264
270
  );
265
271
  const width = FGCA_PAD * 2 + cols.length * colStride - colGap;
266
272
  const height = maxNodeBottom + FGCA_PAD;
@@ -1,6 +1,7 @@
1
1
  import React, { memo, useState } from 'react';
2
2
  import { Handle, Position } from 'reactflow';
3
3
  import type { Factor, Goal } from './types.js';
4
+ import { displayGoalId } from './parse-canonical.js';
4
5
  import type { ThemeTokens } from './theme.js';
5
6
 
6
7
  const NODE_WIDTH = 250;
@@ -23,6 +24,7 @@ export interface GoalNodeData {
23
24
  onAddChild: (parentId: number, parentLevel: number) => void;
24
25
  onDelete: (id: number) => void;
25
26
  onToggleCollapse: (id: number) => void;
27
+ onFactorClick?: (factor: Factor) => void;
26
28
  }
27
29
 
28
30
  const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
@@ -91,7 +93,7 @@ const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
91
93
  {goal.name}
92
94
  </div>
93
95
  <div style={{ fontSize: 10, color: theme.cardMetaColor, marginTop: 2 }}>
94
- ID: {goal.id} | Lvl: {goal.level}
96
+ ID: {displayGoalId(goal)} | Lvl: {goal.level}
95
97
  {goal.tag ? ` | #${goal.tag}` : ''}
96
98
  </div>
97
99
  </div>
@@ -130,6 +132,10 @@ const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
130
132
  {(openPanel === 'positive' ? positive : negative).map((f) => (
131
133
  <div
132
134
  key={f.id}
135
+ onClick={(e) => {
136
+ e.stopPropagation();
137
+ data.onFactorClick?.(f);
138
+ }}
133
139
  style={{
134
140
  background: openPanel === 'positive' ? '#f0fdf4' : '#fef2f2',
135
141
  border: `1px solid ${openPanel === 'positive' ? '#bbf7d0' : '#fecaca'}`,
@@ -138,6 +144,7 @@ const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
138
144
  padding: '4px 6px',
139
145
  fontSize: 10,
140
146
  textAlign: 'left',
147
+ cursor: data.onFactorClick ? 'pointer' : 'default',
141
148
  }}
142
149
  >
143
150
  <div style={{ fontWeight: 700 }}>{f.name}</div>
@@ -12,7 +12,7 @@ import ReactFlow, {
12
12
  type NodeDragHandler,
13
13
  } from 'reactflow';
14
14
  import 'reactflow/dist/style.css';
15
- import type { Goal, GoalTree, GoalTreeLayout, LayoutOptions } from './types.js';
15
+ import type { Factor, Goal, GoalTree, GoalTreeLayout, LayoutOptions } from './types.js';
16
16
  import { layoutGoalTree } from './layout.js';
17
17
  import { reparent, addChild, deleteWithDescendants, moveToBacklog, restoreFromBacklog } from './mutations.js';
18
18
  import type { ThemeTokens } from './theme.js';
@@ -28,9 +28,11 @@ export interface GoalTreeViewProps {
28
28
  theme?: ThemeTokens;
29
29
  readOnly?: boolean;
30
30
  showBacklog?: boolean;
31
+ defaultBacklogOpen?: boolean;
31
32
  showMiniMap?: boolean;
32
33
  onChange?: (event: GoalTreeChange) => void;
33
34
  onEditRequest?: (goal: Goal) => void;
35
+ onFactorClick?: (factor: Factor) => void;
34
36
  }
35
37
 
36
38
  export type GoalTreeChange =
@@ -99,13 +101,16 @@ function GoalTreeViewInner({
99
101
  theme: themeProp,
100
102
  readOnly = false,
101
103
  showBacklog = false,
104
+ defaultBacklogOpen = true,
102
105
  showMiniMap = false,
103
106
  onChange,
104
107
  onEditRequest,
108
+ onFactorClick,
105
109
  }: GoalTreeViewProps): React.ReactElement {
106
110
  const theme = useMemo(() => resolveTheme(themeProp), [themeProp]);
107
111
  const [collapsedIds, setCollapsedIds] = useState<Set<number>>(new Set());
108
112
  const [hoveredId, setHoveredId] = useState<number | null>(null);
113
+ const [isBacklogOpen, setIsBacklogOpen] = useState(defaultBacklogOpen);
109
114
 
110
115
  const { canvas: canvasGoals, backlog: backlogGoals } = useMemo(
111
116
  () => partitionCanvasAndBacklog(tree.goals),
@@ -179,9 +184,10 @@ function GoalTreeViewInner({
179
184
  onAddChild,
180
185
  onDelete: onDeleteNode,
181
186
  onToggleCollapse,
187
+ onFactorClick,
182
188
  },
183
189
  }));
184
- }, [layout.nodes, theme, readOnly, hoveredId, collapsedIds, onAddChild, onDeleteNode, onToggleCollapse]);
190
+ }, [layout.nodes, theme, readOnly, hoveredId, collapsedIds, onAddChild, onDeleteNode, onToggleCollapse, onFactorClick]);
185
191
 
186
192
  const buildEdges = useCallback((): Edge[] => {
187
193
  return layout.edges.map((e) => ({
@@ -286,38 +292,66 @@ function GoalTreeViewInner({
286
292
  <div style={{ display: 'flex', width: '100%', height: '100%' }}>
287
293
  {showBacklogPanel && (
288
294
  <div
289
- data-goaltree-backlog-zone
295
+ {...(isBacklogOpen ? { 'data-goaltree-backlog-zone': true } : {})}
290
296
  style={{
291
- width: 220,
297
+ width: isBacklogOpen ? 220 : 28,
292
298
  flexShrink: 0,
293
299
  borderRight: '1px solid #e2e8f0',
294
- padding: 10,
295
- overflowY: 'auto',
296
300
  background: '#f8fafc',
301
+ display: 'flex',
302
+ flexDirection: 'column',
303
+ position: 'relative',
297
304
  }}
298
305
  >
299
- <div style={{ fontWeight: 700, fontSize: 12, color: '#334155', marginBottom: 8 }}>Backlog</div>
300
- {backlogGoals.map((goal) => (
301
- <div
302
- key={goal.id}
303
- draggable={!readOnly}
304
- onDragStart={(e) => onBacklogItemDragStart(e, goal.id)}
305
- onDoubleClick={() => onEditRequest?.(goal)}
306
- title="Drag onto a canvas card to set its parent"
307
- style={{
308
- padding: 8,
309
- marginBottom: 6,
310
- background: '#ffffff',
311
- border: '1px solid #cbd5e1',
312
- borderRadius: 6,
313
- fontSize: 11,
314
- cursor: readOnly ? 'default' : 'grab',
315
- }}
316
- >
317
- <div style={{ fontWeight: 600 }}>{goal.name}</div>
318
- <div style={{ color: '#64748b' }}>Level: {goal.level}</div>
306
+ <button
307
+ type="button"
308
+ aria-label={isBacklogOpen ? 'Collapse backlog' : 'Expand backlog'}
309
+ title={isBacklogOpen ? 'Collapse backlog' : 'Expand backlog'}
310
+ onClick={() => setIsBacklogOpen((v) => !v)}
311
+ style={{
312
+ alignSelf: 'flex-end',
313
+ margin: '6px 6px 0 6px',
314
+ width: 16,
315
+ height: 16,
316
+ border: '1px solid #cbd5e1',
317
+ borderRadius: 4,
318
+ background: '#ffffff',
319
+ color: '#64748b',
320
+ fontSize: 10,
321
+ lineHeight: '14px',
322
+ padding: 0,
323
+ cursor: 'pointer',
324
+ flexShrink: 0,
325
+ }}
326
+ >
327
+ {isBacklogOpen ? '◀' : '▶'}
328
+ </button>
329
+ {isBacklogOpen && (
330
+ <div style={{ padding: '4px 10px 10px', overflowY: 'auto', flex: 1 }}>
331
+ <div style={{ fontWeight: 700, fontSize: 12, color: '#334155', marginBottom: 8 }}>Backlog</div>
332
+ {backlogGoals.map((goal) => (
333
+ <div
334
+ key={goal.id}
335
+ draggable={!readOnly}
336
+ onDragStart={(e) => onBacklogItemDragStart(e, goal.id)}
337
+ onDoubleClick={() => onEditRequest?.(goal)}
338
+ title="Drag onto a canvas card to set its parent"
339
+ style={{
340
+ padding: 8,
341
+ marginBottom: 6,
342
+ background: '#ffffff',
343
+ border: '1px solid #cbd5e1',
344
+ borderRadius: 6,
345
+ fontSize: 11,
346
+ cursor: readOnly ? 'default' : 'grab',
347
+ }}
348
+ >
349
+ <div style={{ fontWeight: 600 }}>{goal.name}</div>
350
+ <div style={{ color: '#64748b' }}>Level: {goal.level}</div>
351
+ </div>
352
+ ))}
319
353
  </div>
320
- ))}
354
+ )}
321
355
  </div>
322
356
  )}
323
357
 
@@ -297,6 +297,54 @@ describe('GoalTreeView', () => {
297
297
  });
298
298
  });
299
299
 
300
+ it('fires onFactorClick with the clicked factor when a popover item is clicked', async () => {
301
+ const onFactorClick = vi.fn();
302
+ render(<GoalTreeView tree={makeTree()} onFactorClick={onFactorClick} />);
303
+ await waitFor(() => {
304
+ expect(screen.getByText('Cut churn')).toBeTruthy();
305
+ });
306
+
307
+ // Open the negative-factor panel on "Cut churn" (id=3, has risk factor "Support backlog")
308
+ const negativeBadge = screen.getByLabelText('1 negative factor');
309
+ negativeBadge.dispatchEvent(new MouseEvent('click', { bubbles: true }));
310
+
311
+ // The popover item for the risk factor should now be in the DOM
312
+ const factorItem = await waitFor(() => screen.getByText('Support backlog'));
313
+ factorItem.dispatchEvent(new MouseEvent('click', { bubbles: true }));
314
+
315
+ await waitFor(() => expect(onFactorClick).toHaveBeenCalledTimes(1));
316
+ expect(onFactorClick.mock.calls[0][0]).toMatchObject({ id: 10, name: 'Support backlog', impact_type: 'risk' });
317
+ });
318
+
319
+ it('backlog panel is collapsible — toggle hides and restores content', async () => {
320
+ const tree: GoalTree = {
321
+ goal_types: [
322
+ { name: 'Strategy', level: 0 },
323
+ { name: 'Business Goal', level: 1 },
324
+ ],
325
+ goals: [
326
+ { id: 1, name: 'Root goal', type: 'Strategy', level: 0, parent_id: 0 },
327
+ { id: 2, name: 'Orphaned goal', type: 'Business Goal', level: 1, parent_id: 999 },
328
+ ],
329
+ };
330
+ render(<GoalTreeView tree={tree} showBacklog />);
331
+ await waitFor(() => {
332
+ expect(screen.getByText('Backlog')).toBeTruthy();
333
+ });
334
+
335
+ // Collapse
336
+ screen.getByLabelText('Collapse backlog').dispatchEvent(new MouseEvent('click', { bubbles: true }));
337
+ await waitFor(() => {
338
+ expect(screen.queryByText('Backlog')).toBeNull();
339
+ });
340
+
341
+ // Expand again
342
+ screen.getByLabelText('Expand backlog').dispatchEvent(new MouseEvent('click', { bubbles: true }));
343
+ await waitFor(() => {
344
+ expect(screen.getByText('Backlog')).toBeTruthy();
345
+ });
346
+ });
347
+
300
348
  it('fires onEditRequest on double-click', async () => {
301
349
  const onEditRequest = vi.fn();
302
350
  render(<GoalTreeView tree={makeTree()} onEditRequest={onEditRequest} />);
@@ -109,3 +109,45 @@ describe('restoreFromBacklog', () => {
109
109
  expect(r.result!.goals.find(g => g.id === 3)!.parent_id).toBe(1);
110
110
  });
111
111
  });
112
+
113
+ describe('reparent — descendant type relabeling and level cap', () => {
114
+ const DEEP: GoalTree = {
115
+ goal_types: [
116
+ { name: 'Strategy', level: 0 },
117
+ { name: 'Business Goal', level: 1 },
118
+ { name: 'Project', level: 2 },
119
+ { name: 'Task', level: 3 },
120
+ ],
121
+ goals: [
122
+ { id: 1, name: 'Root', type: 'Strategy', level: 0, parent_id: 0 },
123
+ { id: 2, name: 'BG1', type: 'Business Goal', level: 1, parent_id: 1 },
124
+ { id: 3, name: 'Proj1', type: 'Project', level: 2, parent_id: 2 },
125
+ { id: 4, name: 'Task1', type: 'Task', level: 3, parent_id: 3 },
126
+ ],
127
+ };
128
+
129
+ it('relabels descendant types via getTypeForLevel when reparenting shifts levels', () => {
130
+ // Move Proj1 (id=3, level=2) directly under Root (id=1, level=0); delta = -1
131
+ // Task1 (child of Proj1, level=3): new level = 2, type should be 'Project'
132
+ const r = reparent(DEEP, 3, 1);
133
+ expect(r.ok).toBe(true);
134
+ const proj = r.result!.goals.find(g => g.id === 3)!;
135
+ const task = r.result!.goals.find(g => g.id === 4)!;
136
+ expect(proj.level).toBe(1);
137
+ expect(proj.type).toBe('Business Goal');
138
+ expect(task.level).toBe(2);
139
+ expect(task.type).toBe('Project');
140
+ });
141
+
142
+ it('caps descendant level at maxLevel and refuses when that violates GOALS-012', () => {
143
+ // Move Root (id=1, level=0) under Root2 (id=5, level=0); delta = +1
144
+ // BG1: 1→2, Proj1: 2→3 (maxLevel), Task1: 3→4, capped at 3
145
+ // Task1 (capped level=3) under Proj1 (level=3) violates GOALS-012 → refused
146
+ const tree: GoalTree = {
147
+ ...DEEP,
148
+ goals: [...DEEP.goals, { id: 5, name: 'Root2', type: 'Strategy', level: 0, parent_id: 0 }],
149
+ };
150
+ const r = reparent(tree, 1, 5);
151
+ expect(r.ok).toBe(false);
152
+ });
153
+ });
@@ -122,8 +122,10 @@ describe('parseCanonicalGoals', () => {
122
122
  it('maps canonical IDs to internal numeric ids; parent_id is the parent\'s mapped id', () => {
123
123
  const r = parseCanonicalGoals(VALID);
124
124
  expect(r.parsed?.goals[0].id).toBe(1);
125
+ expect(r.parsed?.goals[0].canonical_id).toBe('GOAL-1');
125
126
  expect(r.parsed?.goals[0].parent_id).toBe(0);
126
127
  expect(r.parsed?.goals[1].id).toBe(2);
128
+ expect(r.parsed?.goals[1].canonical_id).toBe('GOAL-2');
127
129
  expect(r.parsed?.goals[1].parent_id).toBe(1);
128
130
  });
129
131
  });
@@ -1,5 +1,6 @@
1
1
  import type { Goal, GoalTree, LayoutOptions, GoalTreeLayout, LaidOutNode, LaidOutEdge } from './types.js';
2
2
  import type { Scope } from '../scope.js';
3
+ import { displayGoalId } from './parse-canonical.js';
3
4
 
4
5
  const DEFAULT_NODE_WIDTH = 250;
5
6
  const DEFAULT_NODE_HEIGHT = 80;
@@ -23,7 +24,7 @@ export function selectScopedGoals(goals: Goal[], scope: Scope): Goal[] {
23
24
  if (!children.has(g.parent_id)) children.set(g.parent_id, []);
24
25
  children.get(g.parent_id)!.push(g.id);
25
26
  }
26
- const root = goals.find(g => String(g.id) === scope.rootGoalId);
27
+ const root = goals.find(g => displayGoalId(g) === scope.rootGoalId);
27
28
  if (!root) return [];
28
29
  const keep = new Set<number>();
29
30
  const stack = [root.id];