@bongos/core 1.19.1014 → 1.19.1015

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 (59) hide show
  1. package/.bongos-core.json +61 -81
  2. package/docs/adr/0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md +4 -1
  3. package/docs/adr/0331-the-sky-is-the-ideators-home.md +6 -1
  4. package/docs/copy-inventory.md +139 -110
  5. package/docs/copy-registry.json +872 -296
  6. package/docs/module-api-changelog.md +2 -0
  7. package/modules/hall-ui/public/atlas.html +1 -1
  8. package/modules/hall-ui/public/blockers.html +1 -1
  9. package/modules/hall-ui/public/board-room.html +1 -1
  10. package/modules/hall-ui/public/collab.html +1 -1
  11. package/modules/hall-ui/public/copy-desk.html +1 -1
  12. package/modules/hall-ui/public/deploy.html +1 -1
  13. package/modules/hall-ui/public/diagrams.html +1 -1
  14. package/modules/hall-ui/public/drachmae.html +1 -1
  15. package/modules/hall-ui/public/fleet.html +1 -1
  16. package/modules/hall-ui/public/gate.html +1 -1
  17. package/modules/hall-ui/public/goal-map.html +125 -52
  18. package/modules/hall-ui/public/goal-map.states.json +29 -5
  19. package/modules/hall-ui/public/goals.html +1 -1
  20. package/modules/hall-ui/public/government.html +1 -1
  21. package/modules/hall-ui/public/idea.html +1 -1
  22. package/modules/hall-ui/public/ideas.html +1 -1
  23. package/modules/hall-ui/public/index.html +1 -1
  24. package/modules/hall-ui/public/modules.html +1 -1
  25. package/modules/hall-ui/public/people.html +1 -1
  26. package/modules/hall-ui/public/primer.html +1 -1
  27. package/modules/hall-ui/public/profile.html +1 -1
  28. package/modules/hall-ui/public/project-settings.html +1 -1
  29. package/modules/hall-ui/public/ranks.html +1 -1
  30. package/modules/hall-ui/public/roadmap.html +1 -1
  31. package/modules/hall-ui/public/roster.html +1 -1
  32. package/modules/hall-ui/public/sessions.html +1 -1
  33. package/modules/hall-ui/public/settings.html +1 -1
  34. package/modules/hall-ui/public/shell.js +11 -0
  35. package/modules/hall-ui/public/sky-data.js +19 -1
  36. package/modules/hall-ui/public/sky-interaction.js +14 -7
  37. package/modules/hall-ui/public/sky-layout.js +2 -1
  38. package/modules/hall-ui/public/sky-tour.js +10 -6
  39. package/modules/hall-ui/public/studio.html +1 -1
  40. package/modules/hall-ui/public/task.html +1 -1
  41. package/modules/hall-ui/public/thinking.css +6 -3
  42. package/modules/hall-ui/public/thinking.html +16 -10
  43. package/modules/hall-ui/public/watch.html +1 -1
  44. package/modules/hall-ui/public/work.html +1 -1
  45. package/package-lock.json +2 -2
  46. package/package.json +1 -1
  47. package/src/bongos/serve-internal.js +4 -0
  48. package/src/module-api.js +1 -1
  49. package/tests/goal_map_page.mjs +346 -211
  50. package/tests/hall_audit.mjs +1 -0
  51. package/tests/hall_nav.mjs +6 -1
  52. package/tests/hall_page_gate_map.mjs +3 -2
  53. package/tests/helpers.mjs +21 -5
  54. package/tests/nav_permission_atoms.mjs +1 -1
  55. package/tests/render_check.mjs +6 -4
  56. package/modules/hall-ui/public/goal-map-lib.js +0 -349
  57. package/modules/hall-ui/public/goal-map.css +0 -117
  58. package/modules/hall-ui/public/goal-map.js +0 -232
  59. package/tests/goal_map_lib.mjs +0 -214
@@ -364,6 +364,7 @@ test('the module doc still records where the 2179 density pass landed', () => {
364
364
  // and the pack's font links like every hall page (the server injects them).
365
365
  const STANDALONE_EXEMPT = {
366
366
  'thinking.html': 'the sky (task 1004231, ADR 0331) — the design of record is its own surface, not the hall chrome',
367
+ 'goal-map.html': 'the project sky (task 1004232) — the same surface as thinking.html, opened in its whole-project lens',
367
368
  };
368
369
 
369
370
  test('every hall page loads style.css and shell.js', () => {
@@ -258,7 +258,12 @@ test('sidebar groups read by audience, in order', () => {
258
258
  // task 1003455 (goal 1000089): 'blockers' joins the same group, after Tasks —
259
259
  // its subject is the project (what's stuck on the owner), not a person or a
260
260
  // craft, and its one data source (GET /blockers) is public like Roadmap's.
261
- assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'blockers', 'board-room', 'ideas', 'thinking', 'copy-desk', 'studio'],
261
+ // task 1004232 (sky part 4, goal 1000095): 'goal-map' — THE PROJECT SKY — joins
262
+ // directly under 'goals'. The goal map that held the URL took no nav slot on
263
+ // purpose (task 1003479, and tests/goal_map_page.mjs pinned it); the owner
264
+ // granted the sky that slot on 2026-09-24 (ADR 0227 §11), under Goals because
265
+ // it is the goals seen as one picture.
266
+ assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'goal-map', 'work', 'blockers', 'board-room', 'ideas', 'thinking', 'copy-desk', 'studio'],
262
267
  'the unlabeled group is the project itself (task 1002425 order)');
263
268
  assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
264
269
  // People (task 1002577): the /scouting directory graduated into the hall.
@@ -48,8 +48,9 @@ const ORACLE = {
48
48
  '/builders/atlas': 'requireBuilderPage',
49
49
  '/builders/modules': 'requireBuilderPage',
50
50
  '/builders/goals': 'requireBuilderPage',
51
- // task 1003479: the goal map. GET /goals/graph is requireBuilder like the goal board it
52
- // maps, so the page sits on the same tier — and takes no nav slot (the sky's rule).
51
+ // task 1003479: the goal map, on the goal board's tier. task 1004232 (sky part 4):
52
+ // the URL now serves THE PROJECT SKY (the same page as /thinking, in its whole-project
53
+ // lens) on the same tier, and holds the nav slot under Goals the map was denied.
53
54
  '/builders/goal-map': 'requireBuilderPage',
54
55
  '/builders/roadmap': 'requireBuilderPage',
55
56
  // Gated at this tier all along, but with no row until task 1003448 walked the
package/tests/helpers.mjs CHANGED
@@ -759,15 +759,27 @@ export const settle = () => new Promise((r) => setTimeout(r, 0));
759
759
  // page keeps on `window.OTBSky.S`. Every request the page made is on
760
760
  // `requests` with its body, so a filing can be checked byte for byte against
761
761
  // what the hall form sends.
762
+ //
763
+ // Two doors on one page (part 4, task 1004232): thinking.html and goal-map.html
764
+ // carry the same markup and the same scripts and differ in the body's
765
+ // `data-sky-lens` (which lens is home). A test names the `page`; each is parsed
766
+ // from its OWN file, so a button lost from either one fails.
762
767
  const SKY_FILES = ['idea-objects.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js'];
763
768
  const SKY_SRC = SKY_FILES.map((f) => [f, fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', f), 'utf8')]);
764
- const SKY_HTML = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/thinking.html'), 'utf8');
765
- const SKY_BODY = (/<body[^>]*>([\s\S]*)<\/body>/.exec(SKY_HTML) || [, ''])[1].replace(/<script[\s\S]*?<\/script>/g, '');
769
+ const SKY_PAGES = {};
770
+ function skyPage(page) {
771
+ if (!SKY_PAGES[page]) {
772
+ const html = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public', `${page}.html`), 'utf8');
773
+ const m = /<body([^>]*)>([\s\S]*)<\/body>/.exec(html) || [, '', ''];
774
+ SKY_PAGES[page] = { attrs: parseAttrs(m[1]), body: m[2].replace(/<script[\s\S]*?<\/script>/g, '') };
775
+ }
776
+ return SKY_PAGES[page];
777
+ }
766
778
 
767
779
  export function bootSkyPage({
768
780
  mine = null, sky = null, versions = null, me = null, template = undefined,
769
781
  viewer = { id: 95, permissions: {} }, status = {}, tourSeen = true, storage = {},
770
- innerWidth = 1280, innerHeight = 800, reduce = true,
782
+ innerWidth = 1280, innerHeight = 800, reduce = true, page = 'thinking',
771
783
  } = {}) {
772
784
  // ── the DOM ──────────────────────────────────────────────────────────────
773
785
  const windowListeners = new Map();
@@ -826,14 +838,18 @@ export function bootSkyPage({
826
838
  }
827
839
  // innerHTML on any element parses into children, so querySelectorAll('.row')
828
840
  // finds what the page just wrote and the page's own listeners get wired.
841
+ const { attrs: bodyAttrs, body: skyBody } = skyPage(page);
829
842
  const root = node('body', {});
843
+ // The body's data-* attributes are the page's dataset (data-sky-lens is what
844
+ // sky-data.js reads to know which lens is home).
845
+ for (const [k, v] of Object.entries(bodyAttrs)) if (k.startsWith('data-')) root.dataset[k.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = v;
830
846
  const patchInnerHtml = (el) => Object.defineProperty(el, 'innerHTML', {
831
847
  get() { return this._html === null ? '' : this._html; },
832
848
  set(v) { this._html = String(v); this._value = null; parse(this, this._html); walk(this, (c) => { patchInnerHtml(c); return null; }); },
833
849
  configurable: true,
834
850
  });
835
851
  patchInnerHtml(root);
836
- parse(root, SKY_BODY);
852
+ parse(root, skyBody);
837
853
  walk(root, (c) => { patchInnerHtml(c); return null; });
838
854
  const byId = (id) => walk(root, (c) => (c.id === id ? c : null));
839
855
  // '.brand h1' and '.jump' are the two descendant selectors the page uses.
@@ -893,7 +909,7 @@ export function bootSkyPage({
893
909
  innerWidth, innerHeight, devicePixelRatio: 1,
894
910
  matchMedia: () => ({ matches: reduce }),
895
911
  localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
896
- location: { hostname: 'builders.cloudbongos.com', search: '', pathname: '/thinking', hash: '', assign: (u) => { out.navigated = u; }, replace: (u) => { out.navigated = u; } },
912
+ location: { hostname: 'builders.cloudbongos.com', search: '', pathname: `/${page}`, hash: '', assign: (u) => { out.navigated = u; }, replace: (u) => { out.navigated = u; } },
897
913
  addEventListener: (type, fn) => { windowListeners.set(type, fn); },
898
914
  requestAnimationFrame: (fn) => { raf = fn; return 1; },
899
915
  performance: { now: () => Date.now() },
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
87
87
  // the file and left the query alone, so a returning browser kept the cached copy —
88
88
  // a fix that reaches nobody looks identical to a fix that does not work.
89
89
 
90
- const SHELL_SHA = 'db45ff41bd9c5fce';
90
+ const SHELL_SHA = '9cc99bb9b7cce948';
91
91
 
92
92
  test('shell.js has not changed without its cache-bust being bumped', () => {
93
93
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
@@ -15,19 +15,21 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
15
  const names = (paths) => statesFilesFor(paths, ROOT).map((f) => path.basename(f));
16
16
 
17
17
  test('a changed page or its own script reaches the states file beside it', () => {
18
- assert.deepEqual(names(['modules/hall-ui/public/goal-map.js']), ['goal-map.states.json']);
18
+ assert.deepEqual(names(['modules/hall-ui/public/goals.js']), ['goals.states.json']);
19
19
  assert.deepEqual(names(['modules/hall-ui/public/goal-map.html']), ['goal-map.states.json']);
20
20
  assert.deepEqual(names(['modules/public-landing/public/projects.html']), ['projects.states.json']);
21
21
  });
22
22
 
23
23
  test('a shared asset reaches every page in its dir whose HTML names it', () => {
24
- assert.deepEqual(names(['modules/hall-ui/public/text-fit.js']), ['goal-map.states.json', 'goals.states.json']);
24
+ // sky-data.js is shared by the two doors of one page (task 1004232): /thinking and /goal-map.
25
+ assert.deepEqual(names(['modules/hall-ui/public/sky-data.js']), ['goal-map.states.json', 'thinking.states.json']);
26
+ assert.deepEqual(names(['modules/hall-ui/public/text-fit.js']), ['goals.states.json']);
25
27
  assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['projects.states.json']);
26
28
  });
27
29
 
28
30
  test('the page a builder changed comes before the pages a shared file reaches, so the render cap never drops it', () => {
29
- assert.deepEqual(names(['modules/hall-ui/public/text-fit.js', 'modules/hall-ui/public/work.js']), ['work.states.json', 'goal-map.states.json', 'goals.states.json']);
30
- assert.deepEqual(names(['modules/hall-ui/public/text-fit.js', 'modules/hall-ui/public/goal-map.js']), ['goal-map.states.json', 'goals.states.json'], 'a page reached both ways is listed once, as its own');
31
+ assert.deepEqual(names(['modules/hall-ui/public/sky-data.js', 'modules/hall-ui/public/work.js']), ['work.states.json', 'goal-map.states.json', 'thinking.states.json']);
32
+ assert.deepEqual(names(['modules/hall-ui/public/sky-data.js', 'modules/hall-ui/public/goal-map.html']), ['goal-map.states.json', 'thinking.states.json'], 'a page reached both ways is listed once, as its own');
31
33
  });
32
34
 
33
35
  test('a path that is not a rendered surface, or a page with no states file, reaches nothing', () => {
@@ -1,349 +0,0 @@
1
- // goal-map-lib.js — the goal map's model and layout, pure (task 1003479, goal
2
- // 1000089, ADR 0227 §6 item 3 and its §10.4 amendment).
3
- //
4
- // WHAT THE MAP IS. Goals are the nodes; an edge says "some tasks in goal A wait on
5
- // tasks in goal B". ADR 0227 measured the real corpus (70 goals, 66 task edges
6
- // across 33 goal pairs) and changed the shape to build: HALF the goals have no
7
- // edge at all, and even the connected half is not one network but a large cluster
8
- // and a scatter of pairs. So this file does three things the picture needs and a
9
- // force layout would get wrong:
10
- // 1. it SPLITS the goals into connected and stand-alone (§10.4 item 1) — the
11
- // stand-alone ones are a list, never dots on a canvas that has nothing to say
12
- // about them;
13
- // 2. it finds the CLUSTERS (§10.4 item 2), largest first, so the view reads as
14
- // "one cluster and a scatter of pairs" and not as a network;
15
- // 3. it lays each cluster out in LAYERS — a goal sits one column right of the
16
- // furthest goal it waits on — which is deterministic (same data, same
17
- // picture, no random seed) and reads as a sequence rather than as a hairball.
18
- //
19
- // IT DRAWS NOTHING THE TABLE DOES NOT ALSO SAY. Every fact in the picture —
20
- // which goals, how many tasks, which category, which goal waits on which — is a
21
- // row or a cell from tableRows(), and the page ships the table by default, not
22
- // behind a toggle (ADR 0227 §5). Category is a WORD on every node as well as a
23
- // colour: the ADR 0192 set is not colour-distinguishable at this many nodes.
24
- //
25
- // NEVER VIEW A OR D. There is no function here that takes a task, and no mode
26
- // that would densify: the only input is the aggregate GET /goals/graph returns,
27
- // which carries no task identity at all (ADR 0227 §7).
28
- //
29
- // Pure — no window, no DOM, no fetch — and Node-require-safe (the blockers-lib.js
30
- // / goals-lib.js precedent) so tests/goal_map_lib.mjs runs the real layout over
31
- // the real fixture instead of agreeing with a copy of it.
32
- (function (root, factory) {
33
- var mod = factory();
34
- if (typeof module !== 'undefined' && module.exports) module.exports = mod;
35
- else root.OTBGoalMapLib = mod;
36
- }(typeof self !== 'undefined' ? self : this, function () {
37
- 'use strict';
38
-
39
- // Geometry. A node is wide enough for ~24 characters of title at the page's
40
- // label size (ADR 0227 measured a 48px pitch as the floor for a label to fit;
41
- // these are well past it), and the gaps leave room for an edge to be seen.
42
- var NODE_W = 168;
43
- // 68, not 60: the extra 8px hold the weight bar (task 1003479 grader follow-up
44
- // — "node weight = task count" was text-only; the bar makes it a real visual
45
- // size, not just a number to read).
46
- var NODE_H = 68;
47
- var GAP_X = 52;
48
- var GAP_Y = 14;
49
- var PAD = 16;
50
- var BLOCK_GAP = 28;
51
- var MIN_ROW_W = 760;
52
- // The categorical colour channel repeats after this many; the WORD on every
53
- // node is what distinguishes them, so a repeat costs nothing.
54
- var CATEGORY_COLOURS = 8;
55
-
56
- function num(id) { return Number(id); }
57
- function byNumericId(a, b) { return num(a) - num(b); }
58
-
59
- // A private goal the viewer cannot read arrives with title null. It stays a node
60
- // (dropping it would falsely isolate its neighbours) and is named as what it is.
61
- function displayTitle(node) { return node.title ? String(node.title) : 'A private goal'; }
62
-
63
- function plural(n, one, many) { return n + ' ' + (n === 1 ? one : (many || one + 's')); }
64
-
65
- // analyse — the graph's structure, with no geometry: nodes by id, who each goal
66
- // waits on and who needs it, the connected/stand-alone split, and the clusters.
67
- function analyse(payload) {
68
- var rawNodes = (payload && Array.isArray(payload.nodes)) ? payload.nodes : [];
69
- var rawEdges = (payload && Array.isArray(payload.edges)) ? payload.edges : [];
70
- var byId = {};
71
- var order = [];
72
- rawNodes.forEach(function (n) {
73
- var id = String(n.goal_id);
74
- if (byId[id]) return;
75
- byId[id] = Object.assign({}, n, { id: id });
76
- order.push(id);
77
- });
78
-
79
- var waits = {};
80
- var needed = {};
81
- var edges = [];
82
- var seen = {};
83
- rawEdges.forEach(function (e) {
84
- var from = String(e.from);
85
- var to = String(e.to);
86
- if (from === to || !byId[from] || !byId[to] || seen[from + '>' + to]) return;
87
- seen[from + '>' + to] = true;
88
- var count = Number(e.count) || 0;
89
- edges.push({ from: from, to: to, count: count });
90
- (waits[from] = waits[from] || []).push({ id: to, count: count });
91
- (needed[to] = needed[to] || []).push({ id: from, count: count });
92
- });
93
- function bySize(a, b) { return (b.count - a.count) || byNumericId(a.id, b.id); }
94
- Object.keys(waits).forEach(function (k) { waits[k].sort(bySize); });
95
- Object.keys(needed).forEach(function (k) { needed[k].sort(bySize); });
96
-
97
- // Union-find over the connected goals only.
98
- var parent = {};
99
- function find(x) { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }
100
- function union(a, b) { var ra = find(a); var rb = find(b); if (ra !== rb) parent[rb] = ra; }
101
- order.forEach(function (id) { if (waits[id] || needed[id]) parent[id] = id; });
102
- edges.forEach(function (e) { union(e.from, e.to); });
103
-
104
- var groups = {};
105
- Object.keys(parent).forEach(function (id) { (groups[find(id)] = groups[find(id)] || []).push(id); });
106
- var clusters = Object.keys(groups).map(function (k) { return groups[k].sort(byNumericId); });
107
- clusters.sort(function (a, b) { return (b.length - a.length) || byNumericId(a[0], b[0]); });
108
- var clusterOf = {};
109
- clusters.forEach(function (ids, i) {
110
- ids.forEach(function (id) { clusterOf[id] = { index: i + 1, size: ids.length }; });
111
- });
112
-
113
- var isolated = order.filter(function (id) { return !parent.hasOwnProperty(id); }).sort(byNumericId);
114
- return {
115
- order: order, byId: byId, edges: edges, waits: waits, needed: needed,
116
- clusters: clusters, clusterOf: clusterOf, isolated: isolated,
117
- connected: order.length - isolated.length,
118
- };
119
- }
120
-
121
- // A goal sits one column right of the furthest goal it waits on. Goal-level
122
- // dependencies CAN loop (task edges never do, but two goals can each hold a task
123
- // the other's waits on), so an edge back onto the current path is ignored for
124
- // layering — it is still drawn, it just does not push anything to the right.
125
- function layerOf(ids, waits) {
126
- var depth = {};
127
- var state = {};
128
- function visit(id) {
129
- if (state[id] === 2) return depth[id];
130
- if (state[id] === 1) return -1;
131
- state[id] = 1;
132
- var d = 0;
133
- (waits[id] || []).forEach(function (dep) {
134
- var dd = visit(dep.id);
135
- if (dd >= 0) d = Math.max(d, dd + 1);
136
- });
137
- state[id] = 2;
138
- depth[id] = d;
139
- return d;
140
- }
141
- ids.forEach(visit);
142
- return depth;
143
- }
144
-
145
- // Order the goals inside each column by the average row of their neighbours in
146
- // the columns already placed, a few alternating sweeps — the standard cheap way
147
- // to cut crossings, and deterministic (ties fall back to goal id).
148
- function orderColumns(columns, neighbours) {
149
- var pos = {};
150
- function reindex() { columns.forEach(function (col) { col.forEach(function (id, i) { pos[id] = i; }); }); }
151
- reindex();
152
- var layerOfId = {};
153
- columns.forEach(function (col, li) { col.forEach(function (id) { layerOfId[id] = li; }); });
154
- for (var sweep = 0; sweep < 4; sweep++) {
155
- var down = sweep % 2 === 0;
156
- var from = down ? 1 : columns.length - 2;
157
- var to = down ? columns.length : -1;
158
- for (var li = from; li !== to; li += down ? 1 : -1) {
159
- var col = columns[li];
160
- var key = {};
161
- col.forEach(function (id) {
162
- var ns = (neighbours[id] || []).filter(function (n) { return down ? layerOfId[n] < li : layerOfId[n] > li; });
163
- key[id] = ns.length ? ns.reduce(function (s, n) { return s + pos[n]; }, 0) / ns.length : pos[id];
164
- });
165
- col.sort(function (a, b) { return (key[a] - key[b]) || byNumericId(a, b); });
166
- col.forEach(function (id, i) { pos[id] = i; });
167
- }
168
- }
169
- }
170
-
171
- // layoutCluster — one cluster's goals in a block of its own: { w, h, at: { id: {x, y} } }.
172
- function layoutCluster(ids, a) {
173
- var depth = layerOf(ids, a.waits);
174
- var neighbours = {};
175
- ids.forEach(function (id) {
176
- neighbours[id] = (a.waits[id] || []).concat(a.needed[id] || []).map(function (n) { return n.id; });
177
- });
178
- var columns = [];
179
- ids.forEach(function (id) { (columns[depth[id]] = columns[depth[id]] || []).push(id); });
180
- columns = columns.map(function (c) { return (c || []).sort(byNumericId); });
181
- orderColumns(columns, neighbours);
182
-
183
- var rows = columns.reduce(function (m, c) { return Math.max(m, c.length); }, 1);
184
- var w = columns.length * NODE_W + (columns.length - 1) * GAP_X;
185
- var h = rows * NODE_H + (rows - 1) * GAP_Y;
186
- var at = {};
187
- columns.forEach(function (col, li) {
188
- var colH = col.length * NODE_H + (col.length - 1) * GAP_Y;
189
- var top = Math.round((h - colH) / 2);
190
- col.forEach(function (id, i) { at[id] = { x: li * (NODE_W + GAP_X), y: top + i * (NODE_H + GAP_Y) }; });
191
- });
192
- return { w: w, h: h, at: at };
193
- }
194
-
195
- function edgePath(dep, dependent) {
196
- // Drawn dependency → dependent, left to right: "this had to ship first".
197
- var x1 = dep.x + NODE_W;
198
- var y1 = dep.y + NODE_H / 2;
199
- var x2 = dependent.x;
200
- var y2 = dependent.y + NODE_H / 2;
201
- var dx = x2 > x1 ? (x2 - x1) / 2 : 44;
202
- return 'M' + x1 + ' ' + y1 + ' C' + (x1 + dx) + ' ' + y1 + ' ' + (x2 - dx) + ' ' + y2 + ' ' + x2 + ' ' + y2;
203
- }
204
-
205
- // layout — every cluster laid out, then packed into rows ("shelves") so the
206
- // big cluster sits on top and the pairs tile beneath it. Returns the SVG's own
207
- // size: the page puts the svg in an overflow-x container and NEVER lets the page
208
- // body scroll sideways (ADR 0227 §5.2).
209
- function layout(a) {
210
- var blocks = a.clusters.map(function (ids) { return layoutCluster(ids, a); });
211
- var rowW = Math.max(MIN_ROW_W, blocks.length ? blocks[0].w : 0);
212
- var rows = [];
213
- var cur = { items: [], w: 0, h: 0 };
214
- blocks.forEach(function (b, i) {
215
- if (cur.items.length && cur.w + BLOCK_GAP + b.w > rowW) { rows.push(cur); cur = { items: [], w: 0, h: 0 }; }
216
- cur.items.push({ i: i, x: cur.items.length ? cur.w + BLOCK_GAP : 0 });
217
- cur.w += (cur.items.length > 1 ? BLOCK_GAP : 0) + b.w;
218
- cur.h = Math.max(cur.h, b.h);
219
- });
220
- if (cur.items.length) rows.push(cur);
221
-
222
- var nodes = [];
223
- var abs = {};
224
- var y = PAD;
225
- var width = 0;
226
- rows.forEach(function (row) {
227
- row.items.forEach(function (it) {
228
- var b = blocks[it.i];
229
- Object.keys(b.at).forEach(function (id) {
230
- var p = { x: PAD + it.x + b.at[id].x, y: y + b.at[id].y };
231
- abs[id] = p;
232
- nodes.push({ id: id, x: p.x, y: p.y, w: NODE_W, h: NODE_H });
233
- });
234
- });
235
- width = Math.max(width, PAD + row.w + PAD);
236
- y += row.h + BLOCK_GAP;
237
- });
238
- var height = rows.length ? y - BLOCK_GAP + PAD : 0;
239
-
240
- var edges = a.edges.map(function (e) {
241
- return {
242
- from: e.from, to: e.to, count: e.count,
243
- d: edgePath(abs[e.to], abs[e.from]),
244
- // 1px for a single task edge, up to 3px — weight is a hint, the table has the count.
245
- width: 1 + Math.min(2, Math.floor(Math.log(Math.max(1, e.count)) / Math.LN2 / 1.5)),
246
- };
247
- });
248
- return { width: width, height: height, nodes: nodes, edges: edges, nodeW: NODE_W, nodeH: NODE_H };
249
- }
250
-
251
- // categories — the legend, and the colour each category wears. Index follows
252
- // the alphabetical order of the labels PRESENT, so a category keeps its colour
253
- // from one visit to the next while the set does not change.
254
- function categories(a) {
255
- var seen = {};
256
- a.order.forEach(function (id) {
257
- var c = a.byId[id].category;
258
- if (c && c.slug && !seen[c.slug]) seen[c.slug] = { slug: c.slug, label: c.label, count: 0 };
259
- if (c && c.slug) seen[c.slug].count++;
260
- });
261
- var list = Object.keys(seen).map(function (k) { return seen[k]; })
262
- .sort(function (x, y) { return String(x.label).localeCompare(String(y.label)) || (x.slug < y.slug ? -1 : 1); });
263
- list.forEach(function (c, i) { c.colour = i % CATEGORY_COLOURS; });
264
- var uncategorised = a.order.filter(function (id) { return !a.byId[id].category; }).length;
265
- return { list: list, uncategorised: uncategorised };
266
- }
267
-
268
- function colourOf(node, cats) {
269
- if (!node.category) return 'none';
270
- for (var i = 0; i < cats.list.length; i++) if (cats.list[i].slug === node.category.slug) return String(cats.list[i].colour);
271
- return 'none';
272
- }
273
-
274
- // weightOf — the node's WEIGHT (ADR 0227 §6 item 3: "node weight = task
275
- // count"), as a 0–1 fraction of the busiest DRAWN goal's task count. Log-scaled
276
- // so one 127-task goal does not flatten every other bar to nothing — the same
277
- // reasoning `edgePath`'s width already uses for edge count. `maxTotal` is the
278
- // caller's (the drawn set, not the whole corpus) so the scale means something
279
- // on THIS map; a maxTotal of 0 (every drawn goal has zero tasks) draws no bar
280
- // rather than dividing by zero into a false full one.
281
- function weightOf(total, maxTotal) {
282
- var t = Math.max(0, Number(total) || 0);
283
- var m = Math.max(0, Number(maxTotal) || 0);
284
- if (!m || !t) return 0;
285
- return Math.min(1, Math.log(1 + t) / Math.log(1 + m));
286
- }
287
-
288
- // tableRows — the primary surface. One row per goal, carrying every fact the
289
- // picture carries plus the two directions of each dependency BY NAME, because
290
- // with this many separate clusters the table holds more of the structure than
291
- // the picture does (§10.4 item 3).
292
- function tableRows(a) {
293
- function named(list) {
294
- return (list || []).map(function (x) { return { id: x.id, title: displayTitle(a.byId[x.id]), count: x.count }; });
295
- }
296
- var rows = a.order.map(function (id) {
297
- var n = a.byId[id];
298
- var cl = a.clusterOf[id] || null;
299
- return {
300
- id: id,
301
- title: displayTitle(n),
302
- isPrivate: !n.title,
303
- version: n.version_id || '',
304
- status: n.status || '',
305
- category: n.category ? n.category.label : '',
306
- done: n.task_done || 0,
307
- total: n.task_total || 0,
308
- waits: named(a.waits[id]),
309
- needed: named(a.needed[id]),
310
- waitsGoals: n.depends_on != null ? n.depends_on : (a.waits[id] || []).length,
311
- neededGoals: n.depended_on_by != null ? n.depended_on_by : (a.needed[id] || []).length,
312
- cluster: cl ? cl.index : 0,
313
- clusterSize: cl ? cl.size : 1,
314
- clusterLabel: cl ? 'Cluster ' + cl.index + ' · ' + plural(cl.size, 'goal') : 'Stands alone',
315
- };
316
- });
317
- // Big cluster first, stand-alone last; within a group the busiest goal first.
318
- rows.sort(function (x, y) {
319
- var cx = x.cluster || 1e9;
320
- var cy = y.cluster || 1e9;
321
- return (cx - cy) || (y.total - x.total) || byNumericId(x.id, y.id);
322
- });
323
- return rows;
324
- }
325
-
326
- // describe — the sentence that states the split out loud (ADR 0227 §10.4 item 1:
327
- // "with the split stated on the page"). Numbers only; no claim the data cannot back.
328
- function describe(a) {
329
- var goals = a.order.length;
330
- if (!goals) return { summary: 'No goal carries any work yet.', graph: '' };
331
- if (!a.connected) {
332
- return {
333
- summary: plural(goals, 'goal') + ' carry work, and no goal waits on another goal’s tasks.',
334
- graph: 'No goal depends on another goal’s work, so there is no graph to draw — the table below is the whole map.',
335
- };
336
- }
337
- var largest = a.clusters[0].length;
338
- var summary = plural(goals, 'goal') + ' carry work. ' + a.connected + ' of them ' + (a.connected === 1 ? 'is' : 'are')
339
- + ' tied to another goal by a dependency; the other ' + a.isolated.length + ' stand alone. '
340
- + 'The ' + a.connected + ' form ' + plural(a.clusters.length, 'cluster') + ', the largest with ' + plural(largest, 'goal') + '.';
341
- return { summary: summary, graph: '' };
342
- }
343
-
344
- return {
345
- analyse: analyse, layout: layout, categories: categories, colourOf: colourOf, weightOf: weightOf,
346
- tableRows: tableRows, describe: describe, displayTitle: displayTitle,
347
- plural: plural, CATEGORY_COLOURS: CATEGORY_COLOURS,
348
- };
349
- }));
@@ -1,117 +0,0 @@
1
- /* goal-map.css — the goal map (task 1003479, goal 1000089; ADR 0227 §6 item 3).
2
- *
3
- * The page is the board archetype: the shell's .scroll panels, the kit's ledger
4
- * for the table, and only the shapes no other page has — the node-link figure, its
5
- * category colour channel and legend, and the stand-alone chips.
6
- *
7
- * COLOUR. Every value below is a var() or a color-mix() of the thirteen contract
8
- * colours (DESIGN.md's Fifteen Rule), so the page inherits dark and light and an
9
- * instance's palette. Category is a colour AND a word on every node — the ADR 0192
10
- * set is not colour-distinguishable at this many nodes — so the eight hues below
11
- * are a redundant channel, and a ninth category repeating the first costs nothing.
12
- * These are page-scoped classes on the figure, never a :root (the pack's :root is
13
- * spliced at (0,1,0); a page's own would neither win reliably nor lose cleanly).
14
- *
15
- * NO SIDEWAYS SCROLL ON THE PAGE. The figure scrolls inside .gm-scroll, which names
16
- * BOTH axes — a lone overflow-x makes the other axis `auto` too and any 1px
17
- * overhang grows a vertical scrollbar nothing can scroll (task 1004130).
18
- */
19
-
20
- .gm-figure { margin: 0; }
21
- .gm-figure .scroll__lede { margin-bottom: var(--sp-md); }
22
-
23
- .gm-scroll {
24
- overflow-x: auto;
25
- overflow-y: hidden;
26
- max-width: 100%;
27
- border: var(--line) solid var(--rule-soft);
28
- border-radius: var(--radius);
29
- background: color-mix(in srgb, var(--ink) 3%, var(--bg-card));
30
- }
31
- .gm-scroll:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
32
- .gm-svg { display: block; }
33
-
34
- /* Eight categorical hues, derived. */
35
- .gm-c0 { --gm-c: var(--sea); }
36
- .gm-c1 { --gm-c: var(--accent-deep); }
37
- .gm-c2 { --gm-c: var(--terracotta); }
38
- .gm-c3 { --gm-c: color-mix(in srgb, var(--sea) 55%, var(--terracotta)); }
39
- .gm-c4 { --gm-c: color-mix(in srgb, var(--sea) 55%, var(--ink)); }
40
- .gm-c5 { --gm-c: color-mix(in srgb, var(--accent-deep) 60%, var(--ink)); }
41
- .gm-c6 { --gm-c: color-mix(in srgb, var(--accent) 55%, var(--sea)); }
42
- .gm-c7 { --gm-c: var(--ink-soft); }
43
-
44
- .gm-edge {
45
- fill: none;
46
- stroke: var(--ink-faint);
47
- opacity: 0.85;
48
- }
49
- .gm-arrowhead { fill: var(--ink-faint); }
50
-
51
- .gm-card {
52
- fill: var(--bg-card);
53
- stroke: var(--rule);
54
- stroke-width: 1;
55
- }
56
- .gm-stripe { fill: var(--gm-c, transparent); }
57
- /* Uncategorised is legal (ADR 0192): a hollow, dashed stripe — present, and plainly not a colour. */
58
- .gm-cnone .gm-stripe { fill: none; stroke: var(--ink-faint); stroke-width: 1; stroke-dasharray: 2 2; }
59
-
60
- .gm-name { font: 600 13px var(--font-body); fill: var(--ink); }
61
- .gm-cat { font: 500 11px var(--font-body); fill: var(--ink-soft); }
62
- .gm-tasks { font: 500 11px var(--font-body); fill: var(--ink-faint); }
63
-
64
- /* The node's WEIGHT (ADR 0227 §6 item 3: "node weight = task count") — a bar
65
- * scaled to the busiest goal drawn on THIS map, so its length is a real second
66
- * channel beside the task-count text, not a decoration repeating it. */
67
- .gm-weight-track { fill: var(--rule-soft); }
68
- .gm-weight-fill { fill: var(--gm-c, var(--ink-soft)); }
69
-
70
- /* The legend is a run of labelled swatches under the figure. */
71
- .gm-legend {
72
- display: flex;
73
- flex-wrap: wrap;
74
- gap: var(--sp-hair) var(--sp-md);
75
- margin: var(--sp-md) 0 0;
76
- padding: 0;
77
- list-style: none;
78
- font-size: 13px;
79
- color: var(--ink-soft);
80
- }
81
- .gm-legend li { display: inline-flex; align-items: center; gap: 6px; min-height: 24px; }
82
- .gm-swatch {
83
- width: 10px;
84
- height: 10px;
85
- border-radius: 2px;
86
- background: var(--gm-c, transparent);
87
- }
88
- .gm-swatch.gm-cnone { background: none; box-shadow: inset 0 0 0 1px var(--ink-faint); }
89
- .gm-n { color: var(--ink-faint); }
90
-
91
- /* The stand-alone goals: chips that wrap, not a grid of cards. */
92
- .gm-alone {
93
- display: flex;
94
- flex-wrap: wrap;
95
- gap: var(--sp-hair);
96
- margin: 0;
97
- padding: 0;
98
- list-style: none;
99
- }
100
- /* A long goal title wraps inside its chip rather than pushing past the panel. */
101
- .gm-chip {
102
- min-height: 24px;
103
- max-width: 100%;
104
- flex-wrap: wrap;
105
- align-items: center;
106
- gap: 0 var(--sp-hair);
107
- white-space: normal;
108
- }
109
-
110
- /* Table cells that list goals. */
111
- /* A floor on the dependency columns: without it a phone squeezes them to a few
112
- characters and every row becomes a tall, empty tower. With it the table is
113
- wider than the card and scrolls inside it (the hall's own ledger rule). */
114
- .gm-deps { margin: 0; padding: 0; list-style: none; min-width: 14rem; }
115
- .gm-deps li { font-size: 13px; line-height: 1.45; }
116
- .gm-none { color: var(--ink-faint); }
117
- .gm-private { color: var(--ink-soft); font-style: italic; }