@bongos/core 1.19.984 → 1.19.985

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.
package/.bongos-core.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.984",
6
- "core_contract": "1.19.984",
7
- "source_commit": "b25c4e8c52129a3c0710d5f8e3c34336fbce056e",
5
+ "core_version": "1.19.985",
6
+ "core_contract": "1.19.985",
7
+ "source_commit": "83b55732b7a45bc4ac27e3f8b4872570c2453a43",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-23T18:10:46.744Z",
9
+ "built_at": "2026-09-23T18:23:03.534Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 518,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2933,
20
- "tree_sha256": "859af3630cbdafa3a20f7080579d49e9b45d4b33dda7298c2484900c697b1afa",
20
+ "tree_sha256": "a3e334e9c129ec8270c4874ec1df68f400624469cfb9652714507b7049ec07f5",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2177,12 +2177,12 @@
2177
2177
  {
2178
2178
  "path": "docs/copy-inventory.md",
2179
2179
  "mode": "0000644",
2180
- "sha256": "359b12acabf9e1befa1a6d940ac1df5392da54ae575afc2d678690d73341f83c"
2180
+ "sha256": "4c03ef54c19fb90810ecf5fe16be7fa65c0966031fecc8a2496cd4bbcb7bed33"
2181
2181
  },
2182
2182
  {
2183
2183
  "path": "docs/copy-registry.json",
2184
2184
  "mode": "0000644",
2185
- "sha256": "cc0c80c9a929b6cb3e1b964de7483bdfe6ab10aec307bd41eb2081385e7b8509"
2185
+ "sha256": "a269cd95254b4e6dace3b2c48becbb1e67d1516bb7387971f760505fee0e4464"
2186
2186
  },
2187
2187
  {
2188
2188
  "path": "docs/design/apex-pass-2-direction.md",
@@ -2712,7 +2712,7 @@
2712
2712
  {
2713
2713
  "path": "docs/module-api-changelog.md",
2714
2714
  "mode": "0000644",
2715
- "sha256": "c77d5612257b8efc9a4e87537512485f46ddde94cb2709c49d739243cd2cffb3"
2715
+ "sha256": "9a567bebe4a58d5b7efeb164ea93f3e134dc7859c12316e2319787ac3e22235c"
2716
2716
  },
2717
2717
  {
2718
2718
  "path": "docs/modules-contract.md",
@@ -4922,12 +4922,12 @@
4922
4922
  {
4923
4923
  "path": "modules/hall-ui/public/goal-forest-lib.js",
4924
4924
  "mode": "0000644",
4925
- "sha256": "ea6258a241922dbf8e3782e01c60a3b03a8c7d9213cdfeddb7bcb91be3227d14"
4925
+ "sha256": "a6e105100fcc8d1e6f15510bb44a8c8a86c5a652924629dfa846474d488637fc"
4926
4926
  },
4927
4927
  {
4928
4928
  "path": "modules/hall-ui/public/goal-forest-render.js",
4929
4929
  "mode": "0000644",
4930
- "sha256": "b39c5544c30fedfc0a42b7cc6c59b6d630b03fd543d5c5bcc34172c494a0addc"
4930
+ "sha256": "69b010afbc63268d446dd1341261a2a3c611562bd28eea81dfc0505928ec5d8e"
4931
4931
  },
4932
4932
  {
4933
4933
  "path": "modules/hall-ui/public/goal-inbox.js",
@@ -8157,12 +8157,12 @@
8157
8157
  {
8158
8158
  "path": "package-lock.json",
8159
8159
  "mode": "0000644",
8160
- "sha256": "fcb023f6492a00c6658961aa61c4c6c45ceffc1fa44e31f247560c5da25ba172"
8160
+ "sha256": "e56dc24d5e58a71b9768ab10c269e8fb9fd40691fd92e8a205e6e0200204393b"
8161
8161
  },
8162
8162
  {
8163
8163
  "path": "package.json",
8164
8164
  "mode": "0000644",
8165
- "sha256": "bfe3f4dd5da3a4007ed4c60941597593a602a47ce324e7b037f0d4fa0e3cad99"
8165
+ "sha256": "e8489984d076fec6055a6c04a71ea53fe859deac7c8bd7c50acb59e45a00c748"
8166
8166
  },
8167
8167
  {
8168
8168
  "path": "public-docs/index.html",
@@ -10187,7 +10187,7 @@
10187
10187
  {
10188
10188
  "path": "src/module-api.js",
10189
10189
  "mode": "0000644",
10190
- "sha256": "09853cbe41b452b48612612661f45c506d9841baf8a70aefb052b9afaef79162"
10190
+ "sha256": "ee0aba3198c03fa51608e06ce5d5f483335085a1aded69fef7afd71f600dfea5"
10191
10191
  },
10192
10192
  {
10193
10193
  "path": "src/module-loader/catalog.js",
@@ -11672,12 +11672,12 @@
11672
11672
  {
11673
11673
  "path": "tests/goal_forest_lib.mjs",
11674
11674
  "mode": "0000644",
11675
- "sha256": "3b309358c195469bee0ab1ce95242914fbc0b1b3def0bb7de3d51dc09dd29c6a"
11675
+ "sha256": "ecced35bef62920319c038ee7d21f7d0914219c7b8640a39b219990fce3357ca"
11676
11676
  },
11677
11677
  {
11678
11678
  "path": "tests/goal_forest_page.mjs",
11679
11679
  "mode": "0000644",
11680
- "sha256": "5c173e79de212e014860f9e3e9b26099decbce4ab675c0f8614e6c1ebdf2a2da"
11680
+ "sha256": "f2fa1534f8a0dc3e4e8eee69f95407eea328584f6b25a4d8ca13e2445a42caa8"
11681
11681
  },
11682
11682
  {
11683
11683
  "path": "tests/goal_graph.mjs",
@@ -368,7 +368,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
368
368
  | `278e8281c82f` | certain | Could not vote: {…} | `modules/hall-ui/public/board-room.js:391` |
369
369
  | `a48e9f098392` | certain | Could not {…} just now. | `modules/hall-ui/public/goals-render.js:1348` |
370
370
  | `cb09ee26014f` | certain | Could not {…}: {…} | `modules/hall-ui/public/government.js:314` |
371
- | `f53f7aaa189f` | certain | Couldn’t load this goal’s task dependencies right now. | `modules/hall-ui/public/goal-forest-render.js:85` |
371
+ | `f53f7aaa189f` | certain | Couldn’t load this goal’s task dependencies right now. | `modules/hall-ui/public/goal-forest-render.js:110` |
372
372
  | `737bef533845` | certain | Couldn’t refresh the queue — try reloading the page. | `modules/hall-ui/public/blockers.js:285` |
373
373
  | `28ec06f6060a` | certain | Counted, not judged: each question is worth the same, and earns its share in proportion to how much real text | `modules/hall-ui/public/ideas.html:194` |
374
374
  | `38827a954f3b` | certain | Create a custom rank first. | `modules/hall-ui/public/government.js:459` |
@@ -650,7 +650,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
650
650
  | `ae84ebe1d735` | certain | Name cannot be blank. | `modules/hall-ui/public/settings.js:761` |
651
651
  | `31e91ee6ce03` | certain | Name must be {…} characters or fewer. | `modules/hall-ui/public/settings.js:766` |
652
652
  | `5e8199266a8a` | certain | Name unvouched | `modules/hall-ui/public/watch.js:374` |
653
- | `83197947ee8d` | certain | Needed by | `modules/hall-ui/public/goal-forest-render.js:120` |
653
+ | `83197947ee8d` | certain | Needed by | `modules/hall-ui/public/goal-forest-render.js:145` |
654
654
  | `3970d299c95a` | certain | Needs work | `modules/hall-ui/public/studio.js:162` |
655
655
  | `7def635421ea` | certain | Needs your attention | `modules/hall-ui/public/goal-inbox.js:559` |
656
656
  | `87f09d983f6c` | certain | New category label | `modules/hall-ui/public/project-settings.js:138` |
@@ -688,7 +688,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
688
688
  | `bef9fe43ba84` | certain | No sessions recorded yet. They land here as builders ship and end sessions. | `modules/hall-ui/public/sessions.js:146` |
689
689
  | `084748fd85b4` | certain | No shipped work is recorded between core {…} and {…}. | `modules/hall-ui/public/deploy.js:340` |
690
690
  | `4485497d87bb` | certain | No specialities yet. Create one below — it takes a name and a craft, and you can train it afterwards. | `modules/hall-ui/public/settings-specialities.js:169` |
691
- | `545dc44ad427` | certain | No task in this goal depends on another task in this goal. | `modules/hall-ui/public/goal-forest-render.js:100` |
691
+ | `545dc44ad427` | certain | No task in this goal depends on another task in this goal. | `modules/hall-ui/public/goal-forest-render.js:125` |
692
692
  | `712b64d8273f` | certain | No tasks match these filters. Try widening them. | `modules/hall-ui/public/work.js:417` |
693
693
  | `78f78d1d1e1f` | certain | No version is in flight right now. | `modules/hall-ui/public/roadmap.js:409` |
694
694
  | `c9800f8b6bc8` | certain | No versions declared yet. | `modules/hall-ui/public/roadmap.js:427` |
@@ -975,7 +975,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
975
975
  | `4894b1d9b0de` | certain | System oversight: people, operations and security. | `modules/hall-ui/public/watch.js:1335` |
976
976
  | `e604fc3ace79` | certain | Task # | `modules/hall-ui/public/blockers.js:139` |
977
977
  | `a0f384b022fd` | certain | Task dependencies | `modules/hall-ui/public/goals.html:140` |
978
- | `91576eb7c880` | certain | Task dependency forest — scrolls sideways inside this box | `modules/hall-ui/public/goal-forest-render.js:120` |
978
+ | `91576eb7c880` | certain | Task dependency forest — scrolls sideways inside this box | `modules/hall-ui/public/goal-forest-render.js:145` |
979
979
  | `dfc967a63d57` | certain | Task lifecycle from backlog to shipped, showing states and transitions and the edge where credits are awarded. | `modules/hall-ui/public/diagrams.html:51` |
980
980
  | `10cc12f37636` | certain | Task sections | `modules/hall-ui/public/work.html:53` |
981
981
  | `0e636ea41b29` | certain | Task status | `modules/hall-ui/public/goals-render.js:354` |
@@ -1125,7 +1125,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1125
1125
  | `b1e63e33f739` | certain | Vulnerability reports, engagement reports, override requests, and the audit log. | `modules/hall-ui/public/watch.html:122` |
1126
1126
  | `411b2ff58094` | certain | Wait for task | `modules/hall-ui/public/task.js:611` |
1127
1127
  | `f2e019bfdbe8` | certain | Waiting to be developed | `modules/hall-ui/public/ideas.html:41` |
1128
- | `8e4f0179e1d3` | certain | Waits on | `modules/hall-ui/public/goal-forest-render.js:120` |
1128
+ | `8e4f0179e1d3` | certain | Waits on | `modules/hall-ui/public/goal-forest-render.js:145` |
1129
1129
  | `3a9cd9a27a84` | certain | Walk the full ladder → | `modules/hall-ui/public/hall-render.js:84` |
1130
1130
  | `18f2f54de2a8` | certain | Watch sections | `modules/hall-ui/public/watch.html:54` |
1131
1131
  | `c1fcfa9b48c2` | certain | Watch — {{worldName}} | `modules/hall-ui/public/watch.html:6` |
@@ -4481,7 +4481,7 @@
4481
4481
  "surface": "builders-hall",
4482
4482
  "text": "Couldn’t load this goal’s task dependencies right now.",
4483
4483
  "file": "modules/hall-ui/public/goal-forest-render.js",
4484
- "line": 85,
4484
+ "line": 110,
4485
4485
  "origin": "js-text-assign",
4486
4486
  "confidence": "certain"
4487
4487
  },
@@ -7019,7 +7019,7 @@
7019
7019
  "surface": "builders-hall",
7020
7020
  "text": "Needed by",
7021
7021
  "file": "modules/hall-ui/public/goal-forest-render.js",
7022
- "line": 120,
7022
+ "line": 145,
7023
7023
  "origin": "js-markup",
7024
7024
  "confidence": "certain"
7025
7025
  },
@@ -7361,7 +7361,7 @@
7361
7361
  "surface": "builders-hall",
7362
7362
  "text": "No task in this goal depends on another task in this goal.",
7363
7363
  "file": "modules/hall-ui/public/goal-forest-render.js",
7364
- "line": 100,
7364
+ "line": 125,
7365
7365
  "origin": "js-text-assign",
7366
7366
  "confidence": "certain"
7367
7367
  },
@@ -9944,7 +9944,7 @@
9944
9944
  "surface": "builders-hall",
9945
9945
  "text": "Task dependency forest — scrolls sideways inside this box",
9946
9946
  "file": "modules/hall-ui/public/goal-forest-render.js",
9947
- "line": 120,
9947
+ "line": 145,
9948
9948
  "origin": "js-markup",
9949
9949
  "confidence": "certain"
9950
9950
  },
@@ -11294,7 +11294,7 @@
11294
11294
  "surface": "builders-hall",
11295
11295
  "text": "Waits on",
11296
11296
  "file": "modules/hall-ui/public/goal-forest-render.js",
11297
- "line": 120,
11297
+ "line": 145,
11298
11298
  "origin": "js-markup",
11299
11299
  "confidence": "certain"
11300
11300
  },
@@ -2442,5 +2442,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2442
2442
  landed since 1.19.982 with no explicit bump. run 35896466235. (task 1002620)
2443
2443
  1.19.984 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2444
2444
  landed since 1.19.983 with no explicit bump. run 35900593334. (task 1002620)
2445
+ 1.19.985 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2446
+ landed since 1.19.984 with no explicit bump. run 35902000024. (task 1002620)
2445
2447
  ---------------------------------------------------------------------------
2446
2448
  ```
@@ -31,17 +31,61 @@
31
31
  'use strict';
32
32
 
33
33
  var NODE_W = 168;
34
- var NODE_H = 52;
34
+ var NODE_H = 66;
35
35
  var GAP_X = 52;
36
36
  var GAP_Y = 14;
37
37
  var PAD = 16;
38
- var TITLE_CHARS = 24;
38
+ var TEXT_INSET = 16;
39
+ var TITLE_LINES = 2;
39
40
 
40
41
  function num(id) { return Number(id); }
41
42
  function byNumericId(a, b) { return num(a) - num(b); }
42
- function clip(text, max) {
43
+
44
+ // Titles are fitted to MEASURED width, not a character budget (task 1004200):
45
+ // proportional glyphs vary too much for any fixed count to fit a fixed box.
46
+ // `measure` is always the caller's, so this file stays DOM-free.
47
+
48
+ // The longest code-point prefix of `chars` that measures within maxWidth with
49
+ // `suffix` appended ('' if none does). Code points, so an emoji is never split.
50
+ function longestPrefix(chars, maxWidth, measure, suffix) {
51
+ var best = '';
52
+ var lo = 1;
53
+ var hi = chars.length;
54
+ while (lo <= hi) {
55
+ var mid = (lo + hi) >> 1;
56
+ var cand = chars.slice(0, mid).join('').replace(/\s+$/, '');
57
+ if (measure(cand + suffix) <= maxWidth) { best = cand; lo = mid + 1; } else { hi = mid - 1; }
58
+ }
59
+ return best;
60
+ }
61
+
62
+ // fitText — `text` whole if it fits, else its longest prefix that fits with an ellipsis.
63
+ function fitText(text, maxWidth, measure) {
43
64
  var t = String(text == null ? '' : text);
44
- return t.length > max ? t.slice(0, max - 1).replace(/\s+$/, '') + '…' : t;
65
+ if (measure(t) <= maxWidth) return t;
66
+ return longestPrefix(Array.from(t), maxWidth, measure, '…') + '…';
67
+ }
68
+
69
+ // fitLines — `text` wrapped at spaces into at most `maxLines` lines of maxWidth,
70
+ // the last ellipsised when the rest will not fit. A word wider than a whole
71
+ // line is broken where it runs out of room, the way the browser would.
72
+ function fitLines(text, maxWidth, maxLines, measure) {
73
+ var rest = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
74
+ var lines = [];
75
+ while (rest && lines.length < maxLines - 1 && measure(rest) > maxWidth) {
76
+ var words = rest.split(' ');
77
+ var line = '';
78
+ for (var i = 0; i < words.length; i++) {
79
+ var next = line ? line + ' ' + words[i] : words[i];
80
+ if (measure(next) > maxWidth) break;
81
+ line = next;
82
+ }
83
+ if (!line) line = longestPrefix(Array.from(words[0]), maxWidth, measure, '') || Array.from(words[0])[0];
84
+ lines.push(line);
85
+ rest = rest.slice(line.length).trim();
86
+ }
87
+ if (rest) lines.push(fitText(rest, maxWidth, measure));
88
+ return lines;
45
89
  }
46
90
  function plural(n, one, many) { return n + ' ' + (n === 1 ? one : (many || one + 's')); }
47
91
 
@@ -224,6 +268,7 @@
224
268
 
225
269
  return {
226
270
  analyse: analyse, layout: layout, tableRows: tableRows, describe: describe,
227
- clip: clip, plural: plural, priorityLabel: priorityLabel, TITLE_CHARS: TITLE_CHARS,
271
+ fitText: fitText, fitLines: fitLines, plural: plural, priorityLabel: priorityLabel,
272
+ TEXT_INSET: TEXT_INSET, TITLE_LINES: TITLE_LINES,
228
273
  };
229
274
  }));
@@ -48,7 +48,21 @@
48
48
  t.textContent = content;
49
49
  return t;
50
50
  }
51
- function drawForest(a, lay) {
51
+ // Node text baselines: the title's lines, then the priority · status line.
52
+ const TITLE_Y = 21;
53
+ const LINE_H = 15;
54
+ const SUB_Y = 52;
55
+ // Titles wrap to the card's measured inner width (task 1004200), which needs
56
+ // the svg in the document — so drawForest collects each node's line elements
57
+ // into `names` and the caller fits them after appending.
58
+ function fitNames(names, maxWidth) {
59
+ names.forEach(({ els, full }) => {
60
+ const measure = (s) => { els[0].textContent = s; return els[0].getComputedTextLength(); };
61
+ const lines = lib.fitLines(full, maxWidth, els.length, measure);
62
+ els.forEach((el, i) => { el.textContent = lines[i] || ''; });
63
+ });
64
+ }
65
+ function drawForest(a, lay, names) {
52
66
  const svg = svgEl('svg', {
53
67
  class: 'gf-svg', viewBox: `0 0 ${lay.width} ${lay.height}`, width: lay.width, height: lay.height,
54
68
  role: 'img', 'aria-labelledby': 'goal-deps-h', focusable: 'false',
@@ -56,6 +70,10 @@
56
70
  const defs = svgEl('defs', {}, svg);
57
71
  const marker = svgEl('marker', { id: 'gf-arrow', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto' }, defs);
58
72
  svgEl('path', { class: 'gf-arrowhead', d: 'M0 0 L10 5 L0 10 z' }, marker);
73
+ // The hard guarantee for a title that could not be measured (drawn while
74
+ // hidden): it is cut at the card's edge instead of running past it.
75
+ const clip = svgEl('clipPath', { id: 'gf-name-clip' }, defs);
76
+ svgEl('rect', { width: lay.nodeW, height: lay.nodeH }, clip);
59
77
 
60
78
  const edges = svgEl('g', { class: 'gf-edges' }, svg);
61
79
  lay.edges.forEach((e) => {
@@ -71,8 +89,15 @@
71
89
  const g = svgEl('g', { class: 'gf-node', transform: `translate(${p.x} ${p.y})` }, nodes);
72
90
  svgEl('title', {}, g).textContent = `#${p.id} ${n.title || ''} — ${n.status || ''} — waits on ${n.depends_on || 0}, needed by ${n.depended_on_by || 0}`;
73
91
  svgEl('rect', { class: 'gf-card', width: p.w, height: p.h, rx: 8 }, g);
74
- text(g, 'gf-name', 16, 21, `#${p.id} ${lib.clip(n.title || '', lib.TITLE_CHARS)}`);
75
- text(g, 'gf-sub', 16, 37, `${lib.priorityLabel(n.priority)} · ${n.status || ''}`);
92
+ const full = `#${p.id} ${n.title || ''}`;
93
+ const els = [];
94
+ for (let i = 0; i < lib.TITLE_LINES; i++) {
95
+ const line = text(g, 'gf-name', lib.TEXT_INSET, TITLE_Y + i * LINE_H, i ? '' : full);
96
+ line.setAttribute('clip-path', 'url(#gf-name-clip)');
97
+ els.push(line);
98
+ }
99
+ names.push({ els, full });
100
+ text(g, 'gf-sub', lib.TEXT_INSET, SUB_Y, `${lib.priorityLabel(n.priority)} · ${n.status || ''}`);
76
101
  });
77
102
  return svg;
78
103
  }
@@ -125,7 +150,13 @@
125
150
  <thead><tr><th>Task</th><th>Status</th><th>Priority</th><th>Waits on</th><th>Needed by</th><th>Chain</th></tr></thead>
126
151
  <tbody>${rows.map(rowHtml).join('')}</tbody>
127
152
  </table>`;
128
- body.querySelector('.gf-scroll').appendChild(drawForest(a, lib.layout(a)));
153
+ const lay = lib.layout(a);
154
+ const names = [];
155
+ body.querySelector('.gf-scroll').appendChild(drawForest(a, lay, names));
156
+ const fit = () => fitNames(names, lay.nodeW - 2 * lib.TEXT_INSET);
157
+ fit();
158
+ // A web font that lands after the first fit changes every width.
159
+ if (document.fonts && document.fonts.status !== 'loaded') document.fonts.ready.then(fit);
129
160
  }
130
161
 
131
162
  async function loadDependencyForest(goalId) {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.984",
3
+ "version": "1.19.985",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.984",
9
+ "version": "1.19.985",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.984",
3
+ "version": "1.19.985",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.984'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.985'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -141,6 +141,67 @@ await test('describe states the numbers plainly, and an empty forest says so', (
141
141
  assert.equal(L.describe(L.analyse({})), 'No task in this goal depends on another task in this goal.');
142
142
  });
143
143
 
144
+ // ---- fitting a title to its card (task 1004200) ------------------------------------
145
+
146
+ // A proportional fake: wide capitals cost three times a narrow letter, so any
147
+ // character budget that fits one title overflows the other.
148
+ const WIDTHS = { W: 12, M: 11, i: 3, '…': 8, '🚀': 14 };
149
+ const measure = (s) => Array.from(s).reduce((w, c) => w + (WIDTHS[c] ?? 6), 0);
150
+
151
+ await test('fitText leaves a title that fits untouched', () => {
152
+ assert.equal(L.fitText('#7 Fix', 136, measure), '#7 Fix');
153
+ });
154
+
155
+ await test('fitText cuts to the MEASURED width, so wide glyphs keep fewer characters than narrow ones', () => {
156
+ const wide = L.fitText('W'.repeat(40), 136, measure);
157
+ const narrow = L.fitText('i'.repeat(80), 136, measure);
158
+ for (const s of [wide, narrow]) {
159
+ assert.ok(measure(s) <= 136, `${s} measures ${measure(s)}, past the box`);
160
+ assert.ok(s.endsWith('…'), 'a cut title says it was cut');
161
+ }
162
+ assert.ok(Array.from(narrow).length > 3 * Array.from(wide).length, 'no fixed character count could produce both');
163
+ assert.equal(wide, 'W'.repeat(10) + '…', 'the LONGEST prefix that fits: 10 W + … = 128px, 11 would be 140px');
164
+ });
165
+
166
+ await test('fitText never ends on a dangling space and never splits an emoji', () => {
167
+ const spaced = L.fitText('ab cd ef gh ij kl mn op qr', 62, measure);
168
+ assert.doesNotMatch(spaced, /\s…$/);
169
+ const emoji = L.fitText('🚀'.repeat(30), 60, measure);
170
+ assert.equal(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(emoji), false, 'no lone high surrogate');
171
+ });
172
+
173
+ await test('fitText returns a bare ellipsis when not even one character fits', () => {
174
+ assert.equal(L.fitText('WWW', 10, measure), '…');
175
+ });
176
+
177
+ await test('fitLines keeps a short title on one line, whole', () => {
178
+ assert.deepEqual(L.fitLines('#7 Fix', 136, 2, measure), ['#7 Fix']);
179
+ });
180
+
181
+ await test('fitLines wraps at a space, and a second line that fits is left whole', () => {
182
+ assert.deepEqual(L.fitLines('aaaa bbbb cccc dddd', 60, 2, measure), ['aaaa bbbb', 'cccc dddd']);
183
+ assert.deepEqual(L.fitLines('a b\n c', 60, 2, measure), ['a b c'], 'runs of whitespace read as one space');
184
+ });
185
+
186
+ await test('fitLines ellipsises the LAST line when the title outruns every line, and no line overflows', () => {
187
+ const lines = L.fitLines('BV1.R26 — Real governance: the constitution becomes enforceable at last', 136, 2, measure);
188
+ assert.equal(lines.length, 2);
189
+ for (const l of lines) assert.ok(measure(l) <= 136, `"${l}" measures ${measure(l)}`);
190
+ assert.ok(lines[1].endsWith('…'));
191
+ assert.ok(!lines[0].endsWith('…'), 'only the last line is cut');
192
+ });
193
+
194
+ await test('fitLines breaks a word wider than a whole line instead of overflowing, and always finishes', () => {
195
+ const lines = L.fitLines('W'.repeat(40), 136, 2, measure);
196
+ assert.deepEqual(lines, ['W'.repeat(11), 'W'.repeat(10) + '…']);
197
+ assert.ok(L.fitLines('WWW', 5, 2, measure).length <= 2, 'a box narrower than one glyph still terminates');
198
+ });
199
+
200
+ await test('fitLines with one line is fitText', () => {
201
+ const t = 'Move the remaining hall pages onto the kit';
202
+ assert.deepEqual(L.fitLines(t, 90, 1, measure), [L.fitText(t, 90, measure)]);
203
+ });
204
+
144
205
  // ---- purity -----------------------------------------------------------------------
145
206
 
146
207
  await test('the lib is pure: no DOM, no fetch, no cross-goal input', () => {
@@ -18,6 +18,7 @@ import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import vm from 'node:vm';
20
20
  import { fileURLToPath } from 'node:url';
21
+ import { createRequire } from 'node:module';
21
22
  import { makeRunner, makeEl, walk } from './helpers.mjs';
22
23
 
23
24
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -45,6 +46,8 @@ async function boot({ status = 200, fail = false, payload = { nodes: [], edges:
45
46
  const el = makeEl(tag);
46
47
  const orig = el.setAttribute.bind(el);
47
48
  el.setAttribute = (k, v) => { orig(k, v); if (k === 'class') el.className = String(v); if (k === 'id') el.id = String(v); };
49
+ // A proportional fake of SVGTextElement's measurement: a W is twice a letter.
50
+ if (tag === 'text') el.getComputedTextLength = () => Array.from(el.textContent).reduce((w, c) => w + (c === 'W' ? 12 : 6), 0);
48
51
  return el;
49
52
  },
50
53
  };
@@ -149,6 +152,31 @@ await test('a populated forest draws a role=img figure with no focus stops, and
149
152
  assert.equal(h.$('goal-deps-count').textContent, '2');
150
153
  });
151
154
 
155
+ await test('a node title wraps to two lines by MEASURED width, clipped as a backstop, and kept whole in the tooltip (task 1004200)', async () => {
156
+ const wide = 'W'.repeat(60);
157
+ const words = 'Move the remaining hall pages onto the kit and retire the aliases';
158
+ const h = await boot({ payload: { nodes: [t(1, { title: wide }), t(2, { title: 'Fix' }), t(3, { title: words })], edges: [e(2, 1), e(3, 1)] } });
159
+ await h.renderers.loadDependencyForest(7);
160
+ const svg = h.$('goal-deps-body').querySelector('svg');
161
+ const lib = createRequire(import.meta.url)(path.join(HALL, 'goal-forest-lib.js'));
162
+ const inner = lib.layout(lib.analyse({ nodes: [t(1), t(2)], edges: [e(2, 1)] })).nodeW - 2 * lib.TEXT_INSET;
163
+ const lines = (id) => svg.querySelectorAll('.gf-node').find((g) => g.attrs.transform && g.querySelector('title').textContent.startsWith(`#${id} `))
164
+ .querySelectorAll('.gf-name').map((l) => l.textContent);
165
+ for (const n of svg.querySelectorAll('.gf-name')) {
166
+ assert.ok(n.getComputedTextLength() <= inner, `"${n.textContent}" measures ${n.getComputedTextLength()}px, past the card's ${inner}px`);
167
+ assert.equal(n.getAttribute('clip-path'), 'url(#gf-name-clip)');
168
+ }
169
+ assert.equal(lines(1).length, lib.TITLE_LINES);
170
+ assert.deepEqual(lines(2), ['#2 Fix', ''], 'a title that fits is left whole, on one line');
171
+ const [first, second] = lines(3);
172
+ assert.ok(words.startsWith(first.replace(/^#3 /, '')) && !first.endsWith('…'), 'line one breaks at a space, uncut');
173
+ assert.ok(second.endsWith('…') && words.includes(second.slice(0, -1)), 'line two continues the title and says it was cut');
174
+ assert.ok(lines(1)[1].endsWith('…'), 'an unbreakable run is cut on the last line, never run past the card');
175
+ assert.ok(svg.querySelector('#gf-name-clip'), 'the clip the lines point at exists');
176
+ const tip = svg.querySelectorAll('title').find((x) => x.textContent.startsWith('#1 '));
177
+ assert.ok(tip.textContent.includes(wide), 'the full title is still readable on hover');
178
+ });
179
+
152
180
  await test('the picture scrolls inside its own region and names both overflow axes', async () => {
153
181
  const h = await boot({ payload: { nodes: [t(1), t(2)], edges: [e(2, 1)] } });
154
182
  await h.renderers.loadDependencyForest(7);