@bongos/core 1.19.1012 → 1.19.1014

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 (76) hide show
  1. package/.bongos-core.json +113 -78
  2. package/docs/adr/0331-the-sky-is-the-ideators-home.md +6 -1
  3. package/docs/copy-inventory.md +120 -66
  4. package/docs/copy-registry.json +984 -498
  5. package/docs/file-map.md +1 -1
  6. package/docs/module-api-changelog.md +4 -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-costs.js +80 -0
  16. package/modules/hall-ui/public/fleet.html +64 -0
  17. package/modules/hall-ui/public/fleet.js +45 -0
  18. package/modules/hall-ui/public/fleet.states.json +14 -0
  19. package/modules/hall-ui/public/gate.html +1 -1
  20. package/modules/hall-ui/public/goal-map.html +1 -1
  21. package/modules/hall-ui/public/goals.html +1 -1
  22. package/modules/hall-ui/public/government.html +1 -1
  23. package/modules/hall-ui/public/idea-objects.js +4 -1
  24. package/modules/hall-ui/public/idea.html +1 -1
  25. package/modules/hall-ui/public/ideas.html +1 -1
  26. package/modules/hall-ui/public/index.html +1 -1
  27. package/modules/hall-ui/public/modules.html +1 -1
  28. package/modules/hall-ui/public/people.html +1 -1
  29. package/modules/hall-ui/public/primer.html +1 -1
  30. package/modules/hall-ui/public/profile.html +1 -1
  31. package/modules/hall-ui/public/project-settings.html +1 -1
  32. package/modules/hall-ui/public/ranks.html +1 -1
  33. package/modules/hall-ui/public/roadmap.html +1 -1
  34. package/modules/hall-ui/public/roster.html +1 -1
  35. package/modules/hall-ui/public/sessions.html +1 -1
  36. package/modules/hall-ui/public/settings.html +1 -1
  37. package/modules/hall-ui/public/shell.js +4 -0
  38. package/modules/hall-ui/public/sky-composer.js +155 -0
  39. package/modules/hall-ui/public/sky-data.js +287 -0
  40. package/modules/hall-ui/public/sky-draw.js +244 -0
  41. package/modules/hall-ui/public/sky-interaction.js +137 -0
  42. package/modules/hall-ui/public/sky-layout.js +81 -0
  43. package/modules/hall-ui/public/sky-panel.js +141 -0
  44. package/modules/hall-ui/public/sky-tour.js +80 -0
  45. package/modules/hall-ui/public/studio.html +1 -1
  46. package/modules/hall-ui/public/task.html +1 -1
  47. package/modules/hall-ui/public/thinking.css +203 -267
  48. package/modules/hall-ui/public/thinking.html +119 -58
  49. package/modules/hall-ui/public/thinking.states.json +33 -9
  50. package/modules/hall-ui/public/watch.html +1 -1
  51. package/modules/hall-ui/public/work.html +1 -1
  52. package/modules/provisioning/routes/provisioning.js +4 -4
  53. package/modules/public-landing/public/projects.html +7 -0
  54. package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -0
  55. package/package-lock.json +2 -2
  56. package/package.json +1 -1
  57. package/src/bongos/auth.js +8 -0
  58. package/src/bongos/routes/me.js +4 -1
  59. package/src/bongos/serve-internal.js +25 -6
  60. package/src/module-api.js +1 -1
  61. package/tests/auth_resolve_failure.mjs +3 -2
  62. package/tests/fleet_cost_view.mjs +189 -0
  63. package/tests/hall_audit.mjs +27 -0
  64. package/tests/hall_nav.mjs +3 -1
  65. package/tests/hall_page_gate_map.mjs +13 -5
  66. package/tests/hall_sky.mjs +393 -405
  67. package/tests/hall_thinking_room.mjs +274 -296
  68. package/tests/helpers.mjs +185 -217
  69. package/tests/ideator_large_dead_thought_proof.mjs +33 -25
  70. package/tests/ideator_traceable_proof.mjs +61 -39
  71. package/tests/nav_permission_atoms.mjs +1 -1
  72. package/modules/hall-ui/public/sky.css +0 -365
  73. package/modules/hall-ui/public/sky.html +0 -79
  74. package/modules/hall-ui/public/sky.js +0 -580
  75. package/modules/hall-ui/public/sky.states.json +0 -20
  76. package/modules/hall-ui/public/thinking.js +0 -418
@@ -1,77 +1,53 @@
1
- // tests/hall_sky.mjs — THE SKY, at the rendered layer
2
- // (task 1004105 / BV2.R26 of goal 1000095, criterion `ideator-sky-tells-the-truth`).
1
+ // tests/hall_sky.mjs — THE SKY, at the rendered layer (task 1004231 / sky part
2
+ // 3 of 5, goal 1000095; ADR 0331, ADR 0227 §11; criterion
3
+ // `ideator-sky-tells-the-truth`).
3
4
  //
4
- // This suite boots the REAL modules/hall-ui/public/sky.js and the REAL
5
- // modules/hall-ui/public/idea-objects.js in a vm and reads the HTML they write.
6
- // Nothing here asserts against a projection built by hand: every row goes in
7
- // shaped the way `GET /inbox/by-builder/:id` shapes one, so a test cannot stay
8
- // green over a page that reads a field the route does not send.
5
+ // This suite boots the REAL page — thinking.html's markup, the REAL
6
+ // idea-objects.js and the REAL seven sky-*.js files — in a vm through
7
+ // bootSkyPage and reads what it wrote. Every trail row goes in shaped the way
8
+ // GET /sky/mine emits one and every well the way GET /sky emits one, so a case
9
+ // cannot stay green over a page that reads a field the routes do not send.
9
10
  //
10
- // WHAT IT IS ACTUALLY GUARDING, and why it is not tests/hall_thinking_room.mjs
11
- // again. Everything the sky draws is already correct and already tested: the
12
- // five states (tests/idea_state.mjs), size (tests/idea_size.mjs), brightness
13
- // (tests/idea_brightness.mjs), the descent (tests/idea_descent_surface.mjs),
14
- // and the room proves the WORDS. What is new here is that the answers become
15
- // GEOMETRY, and a drawing has its own ways of lying that nothing upstream can
16
- // see:
17
- //
18
- // - a diameter computed from a second curve, so one thought is two sizes on
19
- // two pages;
20
- // - a remnant drawn small, which destroys the entire two-channel model — the
21
- // criterion's own proof (BV2.R27) is "large and dead", and if the glyph
22
- // shrinks with brightness there is nothing left to prove;
23
- // - an unreadable brightness drawn as a dim star, which states that work was
24
- // removed when the only thing that happened is that nobody could look;
25
- // - a picture with no words, which is a page a screen reader cannot use;
26
- // - a well drawn around a goal that never caught the thought, or rings
27
- // claiming a goal has no criteria when the truth is nobody asked.
28
- //
29
- // It also holds the one structural promise of spec decision 11: the sky is
30
- // never the only path to anything it shows.
11
+ // WHAT IT GUARDS. The page is the pixel contract of the prototype the owner
12
+ // adopted (docs/design/mocks/sky/), built over the two part-2 reads. What can
13
+ // go wrong is the wiring, not the drawing: a thought drawn in a well the ledger
14
+ // never put it in, a diameter computed from a curve of its own, a remnant
15
+ // shrunk for being dead, an unreadable brightness drawn as a dim star, a
16
+ // heuristic layer (the claude layer, born-of marks) shipped as if it were a
17
+ // ledger fact, a title that became markup, a band that names this instance's
18
+ // versions by hand, and — the structural promise of spec decision 11 — a drawn
19
+ // thought with no row in the drawer. The composer's side is
20
+ // tests/hall_thinking_room.mjs.
31
21
 
32
22
  import assert from 'node:assert/strict';
33
- import fs from 'node:fs/promises';
34
- import path from 'node:path';
35
- import { fileURLToPath } from 'node:url';
36
23
  import { makeRunner, settle, bootSkyPage } from './helpers.mjs';
37
24
 
38
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
39
- const readFile = (rel) => fs.readFile(path.join(ROOT, rel), 'utf8');
40
-
41
25
  const { test, summary } = makeRunner();
26
+ // Values born inside the vm carry that realm's prototypes, so a strict deepEqual
27
+ // reports "same structure but not reference-equal"; compare them as plain data.
28
+ const same = (actual, expected, msg) => assert.deepEqual(JSON.parse(JSON.stringify(actual)), expected, msg);
42
29
 
43
- // The /me read, then a trail page, then the background loop, then /goals and a
44
- // goal detail each — several awaits deep, so one microtask turn is far short.
45
- const drain = async (n = 20) => { for (let i = 0; i < n; i += 1) await settle(); };
30
+ // Five reads in parallel, then the world is built, then the boot line runs —
31
+ // several awaits deep, so one microtask turn is far short.
32
+ const drain = async (n = 30) => { for (let i = 0; i < n; i += 1) await settle(); };
33
+ const boot = async (opts = {}) => { const p = bootSkyPage(opts); await drain(); return p; };
46
34
 
47
- // A trail row in the shape the route emits. Defaults are the dullest possible
48
- // thought — filed, nothing came of it, floating — so every case below states
49
- // only the facts it is actually about.
35
+ // ── the rows, in the routes' own shapes ─────────────────────────────────────
36
+
37
+ // A trail row as GET /sky/mine emits one (the trail's own projection, plus the
38
+ // three attachers). Defaults are the dullest possible thought — filed, nothing
39
+ // came of it, floating — so every case states only the facts it is about.
50
40
  function idea(over = {}) {
51
41
  return {
52
- id: 1,
53
- title: 'A thought',
54
- kind: 'feature',
55
- status: 'open',
56
- captured_at: '2026-09-01T00:00:00.000Z',
57
- triage_note: null,
58
- promoted_to_task_id: null,
59
- promoted_task_title: null,
60
- promoted_task_status: null,
61
- merged_into_idea_id: null,
62
- merged_into_idea_title: null,
63
- lineage_tasks: [],
64
- descent_tasks: [],
42
+ id: 1, title: 'A thought', kind: 'feature', status: 'open', captured_at: '2026-09-01T00:00:00.000Z',
43
+ triage_note: null, promoted_to_task_id: null, promoted_task_title: null, promoted_task_status: null,
44
+ merged_into_idea_id: null, merged_into_idea_title: null, grade: 'quick', field_values: null,
45
+ lineage_tasks: [], descent_tasks: [],
65
46
  size: { credits: 0, payments: 0, scale: 0, historical: false },
66
- state: {
67
- state: 'protostar', position: 'open-space', wellGoalId: null,
68
- awaitingNod: false, brightness: null, hasWork: false, attributable: true,
69
- },
47
+ state: { state: 'protostar', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: true },
70
48
  ...over,
71
49
  };
72
50
  }
73
- const page = (ideas, over = {}) => ({ ideas, total: ideas.length, offset: 0, limit: 100, ...over });
74
-
75
51
  const star = (over = {}) => idea({
76
52
  id: 10, title: 'It ignited', status: 'promoted',
77
53
  lineage_tasks: [{ task_id: 500, origin: 'promote', title: 'The task', status: 'shipped', goal_id: 7 }],
@@ -85,374 +61,386 @@ const remnant = (over = {}) => star({
85
61
  state: { state: 'remnant', position: 'well', wellGoalId: '7', awaitingNod: false, brightness: 0, hasWork: true, attributable: true },
86
62
  ...over,
87
63
  });
64
+ const satellite = (over = {}) => idea({
65
+ id: 13, title: 'Folded', status: 'merged', merged_into_idea_id: 10, merged_into_idea_title: 'It ignited',
66
+ state: { state: 'satellite', position: 'well', wellGoalId: '7', awaitingNod: false, brightness: 1, hasWork: true, attributable: true },
67
+ ...over,
68
+ });
88
69
  const coldBody = (over = {}) => idea({
89
70
  id: 12, title: 'It never ignited', status: 'discarded',
90
- size: { credits: 0, payments: 0, scale: 0, historical: false },
91
71
  state: { state: 'cold-body', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: true },
92
72
  ...over,
93
73
  });
74
+ const waiting = (over = {}) => idea({
75
+ id: 14, title: 'Caught, waiting for a nod', status: 'promoted',
76
+ lineage_tasks: [{ task_id: 9001, origin: 'route', title: 'Caught, waiting for a nod', status: 'backlog', goal_id: 7 }],
77
+ state: { state: 'protostar', position: 'well', wellGoalId: '7', awaitingNod: true, brightness: 1, hasWork: false, attributable: true },
78
+ ...over,
79
+ });
80
+ const FIVE = () => [star(), idea(), satellite(), remnant(), coldBody()];
81
+ const mine = (ideas, over = {}) => ({ builder_id: 95, ideas, total: ideas.length, cap: 1000, truncated: false, ...over });
94
82
 
95
- // The diameter a glyph was drawn at, and the object it says it is, pulled off
96
- // the markup by the idea's own button so a case never has to guess which glyph
97
- // belongs to which thought.
98
- function glyphOf(html, ideaId) {
99
- const re = new RegExp(`data-idea="${ideaId}"[\\s\\S]*?<span class="sky-glyph"([^>]*)>`);
100
- const m = re.exec(html);
101
- if (!m) return null;
102
- const attrs = m[1];
83
+ // A well as GET /sky emits one: the graph's node plus its criteria and its
84
+ // compact tasks.
85
+ function well(over = {}) {
103
86
  return {
104
- object: (/data-object="([^"]+)"/.exec(attrs) || [])[1] || null,
105
- d: Number((/--d:(\d+)px/.exec(attrs) || [])[1]),
106
- lit: Number((/--lit:([\d.]+)/.exec(attrs) || [])[1]),
107
- unknownSize: /data-size="unknown"/.test(attrs),
87
+ goal_id: '7', title: 'The well — a goal that holds work', subtitle: null, status: 'open', version_id: 'V2', category: null,
88
+ task_total: 3, task_done: 1, created_at: '2026-06-01T00:00:00.000Z',
89
+ criteria: [
90
+ { id: '70', criterion_id: 'c1', criterion_md: 'The first ring is met', satisfied: true, pending_review: false, task_ids: ['500'] },
91
+ { id: '71', criterion_id: 'c2', criterion_md: 'The second ring is open', satisfied: false, pending_review: false, task_ids: ['501', '502'] },
92
+ ],
93
+ tasks: [
94
+ { id: '500', title: 'The task', status: 'shipped', credits_reward: 60, created_on: '2026-07-01', idea_id: '10' },
95
+ { id: '501', title: 'A held task', status: 'active', credits_reward: 40, created_on: '2026-08-01', idea_id: null },
96
+ { id: '502', title: 'A claimable task', status: 'ready', credits_reward: 30, created_on: '2026-08-02', idea_id: null },
97
+ ],
98
+ ...over,
108
99
  };
109
100
  }
110
- const labelOf = (html, ideaId) => (new RegExp(`data-idea="${ideaId}"[\\s\\S]*?aria-label="([^"]*)"`).exec(html) || [])[1] || '';
101
+ const sky = (goals, edges = []) => ({ goals, edges, counts: { goals: goals.length, edges: edges.length, criteria: 0, tasks: 0 }, cache: { hit: false, ttl_seconds: 60, taken_at: '2026-09-25T00:00:00.000Z' } });
102
+ const versions = () => ({ versions: [
103
+ { id: 'V2', status: 'building', shipped_at: null, started_at: '2026-08-01T00:00:00.000Z' },
104
+ { id: 'V1', status: 'shipped', shipped_at: '2026-05-12T00:00:00.000Z', started_at: '2026-05-01T00:00:00.000Z' },
105
+ ] });
106
+
107
+ const glyphWidth = (rows, id) => Number((new RegExp(`data-id="${id}"[\\s\\S]*?<span class="mk [^"]*" style="width:(\\d+)px`).exec(rows) || [])[1]);
108
+ const glyphClass = (rows, id) => (new RegExp(`data-id="${id}"[\\s\\S]*?<span class="mk ([^"]*)"`).exec(rows) || [])[1] || '';
109
+ const findIdea = (p, id) => p.S().ideas.find((i) => i.id === String(id));
110
+
111
+ // ═══ the reads ═══════════════════════════════════════════════════════════════
112
+
113
+ await test('it reads the whole sky in five requests, and never the old trail read', async () => {
114
+ const p = await boot({ mine: mine(FIVE()), sky: sky([well()]), versions: versions() });
115
+ const urls = p.requests.map((r) => r.url);
116
+ for (const want of ['/api/bongos/sky', '/api/bongos/sky/mine', '/api/bongos/me', '/api/bongos/inbox/template']) assert.ok(urls.includes(want), `reads ${want}`);
117
+ assert.ok(urls.some((u) => u.startsWith('/api/bongos/versions')), 'reads the versions for the bands');
118
+ assert.ok(!urls.some((u) => /by-builder|\/goals\/\d+$|\/goals$/.test(u)), 'the room\'s paged trail read and the per-goal reads are gone — one read each (ADR 0331)');
119
+ assert.equal(urls.filter((u) => u.endsWith('/sky/mine')).length, 1, 'the trail is one request, whole — the route pages it server-side');
120
+ assert.ok(p.ready(), 'the page booted');
121
+ });
111
122
 
112
- // ═══ the five objects, in the shared vocabulary ═════════════════════════════
123
+ await test('the trail is the VIEWER\'s — /sky/mine carries no id, and nothing in the URL could name another builder', async () => {
124
+ const p = await boot({ mine: mine(FIVE()) });
125
+ assert.ok(p.requests.some((r) => r.url === '/api/bongos/sky/mine'), 'the exact path, no id');
126
+ });
127
+
128
+ // ═══ the five objects ════════════════════════════════════════════════════════
113
129
 
114
130
  await test('every one of the five draws its own object, and the word comes from the shared table', async () => {
115
- const all = [
116
- star(),
117
- remnant(),
118
- coldBody(),
119
- idea({ id: 13, title: 'Folded', status: 'merged', merged_into_idea_id: 999, merged_into_idea_title: 'The host',
120
- state: { state: 'satellite', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: 1, hasWork: true, attributable: true } }),
121
- idea({ id: 14, title: 'Waiting' }),
122
- ];
123
- const p = bootSkyPage({ pages: [page(all)] });
124
- await drain();
125
- const html = p.field();
126
- const seen = [10, 11, 12, 13, 14].map((id) => glyphOf(html, id).object);
127
- assert.deepEqual(seen, ['star', 'remnant', 'cold-body', 'satellite', 'protostar']);
128
- // The words are the shared table's, never this page's.
129
- const objects = p.objects();
130
- for (const id of objects.ORDER) assert.ok(p.legend().includes(objects.objectFor(id).label), `the legend says "${objects.objectFor(id).label}"`);
131
- });
132
-
133
- await test('the legend is built from ORDER, so the page cannot grow a sixth object or lose one', async () => {
134
- const p = bootSkyPage({ pages: [page([star()])] });
135
- await drain();
136
- const marks = [...p.legend().matchAll(/class="sky-glyph" data-object="([^"]+)"/g)].map((m) => m[1]);
137
- assert.deepEqual(marks, [...p.objects().ORDER]);
138
- });
139
-
140
- // ═══ size is geometry, and the curve is the server's ════════════════════════
141
-
142
- await test('the diameter tracks the server scale, and nothing on this page re-compresses it', async () => {
143
- const small = star({ id: 20, size: { credits: 12, payments: 1, scale: 0.1, historical: false } });
144
- const big = star({ id: 21, size: { credits: 2400, payments: 40, scale: 1, historical: false } });
145
- const p = bootSkyPage({ pages: [page([small, big])] });
146
- await drain();
147
- const a = glyphOf(p.field(), 20);
148
- const b = glyphOf(p.field(), 21);
149
- assert.ok(b.d > a.d, 'a bigger scale draws a bigger body');
150
- // The mapping is AFFINE in the server's scale — that is the whole claim. A
151
- // page taking its own log of an already-compressed figure is how the sky and
152
- // the room end up drawing one thought at two sizes, and the only way to catch
153
- // it is to check a third point against the line the other two define.
154
- const mid = star({ id: 22, size: { credits: 200, payments: 4, scale: 0.55, historical: false } });
155
- const q = bootSkyPage({ pages: [page([small, big, mid])] });
156
- await drain();
157
- const c = glyphOf(q.field(), 22);
158
- const expected = a.d + (b.d - a.d) * ((0.55 - 0.1) / (1 - 0.1));
159
- assert.ok(Math.abs(c.d - expected) <= 1, `0.55 lands on the line the endpoints define (${c.d} vs ~${expected.toFixed(1)})`);
160
- });
161
-
162
- await test('A LARGE DEAD THOUGHT RENDERS LARGE AND DEAD — it keeps its diameter and loses its fill', async () => {
163
- // The criterion's own picture, and the thing BV2.R27 goes on to prove live.
164
- // The two rows differ ONLY in what happened to the work: same scale on the
165
- // remnant as on the star, nothing at all on the cold body.
166
- const p = bootSkyPage({ pages: [page([star(), remnant(), coldBody()])] });
167
- await drain();
168
- const html = p.field();
169
- const alive = glyphOf(html, 10);
170
- const dead = glyphOf(html, 11);
171
- const never = glyphOf(html, 12);
172
- assert.equal(dead.d, alive.d, 'size never falls: the remnant is exactly as big as the star');
173
- assert.ok(dead.d > never.d, 'and it is visibly bigger than a thought that never mattered');
174
- assert.equal(dead.object, 'remnant');
175
- assert.equal(never.object, 'cold-body');
176
- // Distinguishable in WORDS too, not only in shape — the picture is not the
177
- // only channel a reader has.
178
- assert.match(labelOf(html, 11), /burned out/);
179
- assert.match(labelOf(html, 11), /none of it still alive/);
180
- assert.match(labelOf(html, 12), /declined/);
181
- });
182
-
183
- await test('a size that could not be read draws at the floor and says so, never as a measured zero', async () => {
184
- const unreadable = star({ id: 30, size: null });
185
- const zero = coldBody({ id: 31 });
186
- const p = bootSkyPage({ pages: [page([unreadable, zero])] });
187
- await drain();
188
- const a = glyphOf(p.field(), 30);
189
- const b = glyphOf(p.field(), 31);
190
- assert.equal(a.unknownSize, true, 'the unreadable one is marked');
191
- assert.equal(b.unknownSize, false, 'a real zero is not');
192
- assert.match(labelOf(p.field(), 30), /size unknown/);
193
- p.pick(30);
194
- assert.match(p.panel(), /unknown/, 'the panel says unknown rather than a number');
195
- assert.ok(!/size — the work it caused<\/span><span class="sky-fact__v">0/.test(p.panel()));
196
- });
197
-
198
- await test('a historical size carries its marker wherever the number is shown', async () => {
199
- const p = bootSkyPage({ pages: [page([star({ size: { credits: 96, payments: 3, scale: 0.71, historical: true } })])] });
200
- await drain();
201
- p.pick(10);
202
- assert.match(p.panel(), /part historical/);
203
- assert.match(p.panel(), /not payouts that were made/);
204
- });
205
-
206
- // ═══ brightness: the three answers stay three ═══════════════════════════════
207
-
208
- await test('brightness rides the glyph, and a dimmer star is drawn dimmer', async () => {
209
- const full = star({ id: 40 });
210
- const half = star({ id: 41, state: { ...star().state, brightness: 0.5 } });
211
- const p = bootSkyPage({ pages: [page([full, half])] });
212
- await drain();
213
- assert.equal(glyphOf(p.field(), 40).lit, 1);
214
- assert.equal(glyphOf(p.field(), 41).lit, 0.5);
215
- assert.match(labelOf(p.field(), 41), /50% still alive/);
131
+ const p = await boot({ mine: mine(FIVE()), sky: sky([well()]), versions: versions() });
132
+ p.key('l');
133
+ const rows = p.rows(); const filters = p.filters(); const objects = p.objects();
134
+ for (const key of objects.ORDER) {
135
+ const cls = key === 'cold-body' ? 'cold' : key;
136
+ assert.ok(new RegExp(`class="mk ${cls}(?: |")`).test(rows), `a row draws the ${key} mark`);
137
+ assert.ok(filters.includes(objects.objectFor(key).label), `the drawer's filter says "${objects.objectFor(key).label}"`);
138
+ }
139
+ const order = objects.ORDER.map((k) => filters.indexOf(`data-f="${k}"`));
140
+ same(order, [...order].sort((a, b) => a - b), 'the filters sit in the shared ORDER');
141
+ });
142
+
143
+ await test('the state wins over the row\'s status — the page re-derives nothing', async () => {
144
+ const p = await boot({ mine: mine([star({ status: 'discarded' })]) });
145
+ p.key('l');
146
+ assert.equal(glyphClass(p.rows(), 10).split(' ')[0], 'star', 'a discarded row the resolver called a star is drawn as a star');
147
+ });
148
+
149
+ await test('a state the page has never heard of is drawn through, not swallowed', async () => {
150
+ const p = await boot({ mine: mine([idea({ id: 99, state: { state: 'nebula', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: true } })]) });
151
+ p.key('l');
152
+ assert.equal(glyphClass(p.rows(), 99), 'nebula');
153
+ assert.equal(findIdea(p, 99).st, 'nebula');
154
+ });
155
+
156
+ // ═══ size and brightness — the two channels ══════════════════════════════════
157
+
158
+ await test('the diameter is the server scale and nothing else — one curve, drawn by no page', async () => {
159
+ const p = await boot({ mine: mine([star({ size: { credits: 240, payments: 4, scale: 0.5, historical: false } }), star({ id: 20, size: { credits: 9000, payments: 40, scale: 0.5, historical: false } })]) });
160
+ const SKY = p.SKY();
161
+ assert.equal(SKY.diameter(findIdea(p, 10)), SKY.diameter(findIdea(p, 20)), 'same scale, same diameter — the credits do not get a second curve here');
162
+ assert.equal(SKY.diameter(findIdea(p, 10)), 6 + 22 * 0.5, 'affine in scale (the prototype\'s 6 + 22·scale)');
163
+ assert.equal(SKY.diameter({ size: null }), 6, 'no size draws at the floor');
164
+ });
165
+
166
+ await test('A LARGE DEAD THOUGHT IS LARGE AND DEAD — the remnant keeps its diameter and loses its light', async () => {
167
+ const p = await boot({ mine: mine([star(), remnant()]), sky: sky([well()]) });
168
+ p.key('l');
169
+ const rows = p.rows();
170
+ assert.equal(glyphWidth(rows, 11), glyphWidth(rows, 10), 'the dead one is drawn exactly as wide as the live one it was paid like');
171
+ assert.equal(glyphClass(rows, 11), 'remnant');
172
+ assert.equal(findIdea(p, 11).lit, 0);
173
+ p.SKY().pick({ kind: 'idea', ref: findIdea(p, 11) }, false);
174
+ assert.match(p.panel(), /none of it still alive/, 'and the words say so');
175
+ assert.match(p.panel(), /180/, 'while the size is spoken whole');
216
176
  });
217
177
 
218
178
  await test('AN UNREADABLE BRIGHTNESS LEAVES THE STAR FULLY LIT and puts the doubt in words', async () => {
219
- // idea-state.js step 3, one layer up and one channel over: `attributable:
220
- // false` means the removal facts could not be read, and dimming on it would
221
- // draw a healthy instance as a field of half-dead stars the moment the
222
- // lifecycle module failed to mount.
223
- const unknown = star({ id: 50, state: { ...star().state, brightness: null, attributable: false } });
224
- const out = star({ id: 51, state: { ...star().state, brightness: 0, attributable: true } });
225
- const p = bootSkyPage({ pages: [page([unknown, out])] });
226
- await drain();
227
- assert.equal(glyphOf(p.field(), 50).lit, 1, 'unreadable is drawn at full');
228
- assert.equal(glyphOf(p.field(), 51).lit, 0, 'a confirmed-dead descent is not');
229
- assert.match(labelOf(p.field(), 50), /still alive — unknown/);
230
- });
231
-
232
- await test('the panel draws no brightness BAR for a share nobody could measure', async () => {
233
- // A bar at zero and a bar that could not be measured look identical, and one
234
- // of them is a claim. The words carry it instead.
235
- const unknown = star({ id: 60, state: { ...star().state, brightness: null, attributable: false } });
236
- const p = bootSkyPage({ pages: [page([unknown])] });
237
- await drain();
238
- p.pick(60);
239
- assert.ok(!/sky-bar/.test(p.panel()), 'no bar on an unreadable share');
179
+ const p = await boot({ mine: mine([star({ state: { state: 'star', position: 'well', wellGoalId: '7', awaitingNod: false, brightness: null, hasWork: true, attributable: false } })]) });
180
+ const i = findIdea(p, 10);
181
+ assert.equal(i.lit, 1, 'nobody could look, so nothing is drawn as lost');
182
+ assert.equal(p.SKY().brightnessWord(i), 'still alive — unknown');
183
+ p.SKY().pick({ kind: 'idea', ref: i }, false);
240
184
  assert.match(p.panel(), /still alive — unknown/);
185
+ });
241
186
 
242
- const q = bootSkyPage({ pages: [page([star({ id: 61, state: { ...star().state, brightness: 0 } })])] });
243
- await drain();
244
- q.pick(61);
245
- assert.match(q.panel(), /sky-bar[^>]*data-lit="out"/, 'a measured zero DOES get a bar, drawn out');
246
- });
247
-
248
- await test('a thought with no work yet says there is nothing to be alive, not that it died', async () => {
249
- const p = bootSkyPage({ pages: [page([idea({ id: 70 })])] });
250
- await drain();
251
- p.pick(70);
252
- assert.match(p.panel(), /nothing to be alive yet/);
253
- assert.ok(!/none of it still alive/.test(p.panel()));
254
- });
255
-
256
- // ═══ gravity: the wells, and the rings inside them ══════════════════════════
257
-
258
- await test('a well is the goal that HOLDS the work, and its name comes from the goals read', async () => {
259
- const p = bootSkyPage({
260
- pages: [page([star(), idea({ id: 80 })])],
261
- goals: [{ id: '7', title: 'The catching goal' }],
262
- });
263
- await drain();
264
- assert.match(p.field(), /The catching goal/);
265
- // The floating one is not in it.
266
- assert.match(p.field(), /Open space/);
267
- assert.match(p.field(), /no goal has claimed this one/);
268
- });
269
-
270
- await test('a well whose goal could not be named keeps its id — never a blank label', async () => {
271
- const p = bootSkyPage({ pages: [page([star()])], goals: null });
272
- await drain();
273
- assert.match(p.field(), /Goal 7/);
274
- });
275
-
276
- await test('the rings are the criteria, closing as each is satisfied', async () => {
277
- const p = bootSkyPage({
278
- pages: [page([star()])],
279
- goals: [{ id: '7', title: 'The catching goal' }],
280
- criteria: { 7: [{ satisfied: true }, { satisfied: true }, { satisfied: false }] },
281
- });
282
- await drain();
283
- const rings = [...p.field().matchAll(/class="sky-ring"( data-closed="1")?/g)].map((m) => !!m[1]);
284
- assert.deepEqual(rings, [true, true, false]);
285
- assert.match(p.field(), /2 of 3 criteria met/);
286
- });
287
-
288
- await test('a goal whose criteria were NOT READ draws no rings, and does not claim it has none', async () => {
289
- const p = bootSkyPage({
290
- pages: [page([star()])],
291
- goals: [{ id: '7', title: 'The catching goal' }],
292
- criteria: {},
293
- });
294
- await drain();
295
- assert.ok(!/sky-ring/.test(p.field()), 'no rings at all');
296
- assert.ok(!/0 of 0 criteria/.test(p.field()), 'and no claim about how many there are');
297
- assert.match(p.field(), /1 thought/, 'it falls back to what it does know');
298
- });
299
-
300
- await test('a satellite is drawn ON the thought it was folded into, when that one is in view', async () => {
301
- const host = star({ id: 90 });
302
- const moon = idea({
303
- id: 91, title: 'Folded in', status: 'merged', merged_into_idea_id: 90, merged_into_idea_title: 'It ignited',
304
- state: { state: 'satellite', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: 1, hasWork: true, attributable: true },
305
- });
306
- const p = bootSkyPage({ pages: [page([host, moon])], goals: [{ id: '7', title: 'The catching goal' }] });
307
- await drain();
308
- const html = p.field();
309
- // Both in the HOST's well, not one in open space — the satellite's own
310
- // wellGoalId is null, so grouping by that alone would separate them.
311
- assert.ok(!/Open space/.test(html), 'the satellite did not float off on its own');
312
- const pos = (id) => {
313
- const m = new RegExp(`data-idea="${id}"[\\s\\S]*?left:([\\d.-]+)%;top:([\\d.-]+)%`).exec(html);
314
- return { left: Number(m[1]), top: Number(m[2]) };
315
- };
316
- const a = pos(90);
317
- const b = pos(91);
318
- const gap = Math.hypot(a.left - b.left, a.top - b.top);
319
- assert.ok(gap > 0 && gap < 12, `it orbits the host rather than taking a slot of its own (gap ${gap.toFixed(1)}%)`);
320
- });
321
-
322
- await test('a protostar in a well says a nod is waiting; one in open space says nobody claimed it', async () => {
323
- const waiting = idea({
324
- id: 100, title: 'Caught, not lit', status: 'promoted',
325
- lineage_tasks: [{ task_id: 7, origin: 'promote', title: 'T', status: 'backlog', goal_id: 7 }],
326
- state: { state: 'protostar', position: 'well', wellGoalId: '7', awaitingNod: true, brightness: null, hasWork: false, attributable: true },
327
- });
328
- const floating = idea({ id: 101, title: 'Nobody caught it' });
329
- const p = bootSkyPage({ pages: [page([waiting, floating])], goals: [{ id: '7', title: 'A goal' }] });
330
- await drain();
331
- assert.match(labelOf(p.field(), 100), /waiting for a nod/);
332
- assert.match(labelOf(p.field(), 101), /nothing has claimed it/);
333
- });
334
-
335
- // ═══ never the only path ════════════════════════════════════════════════════
336
-
337
- await test('every thought carries its whole sentence, so the picture is readable without seeing it', async () => {
338
- const p = bootSkyPage({ pages: [page([star({ id: 110, title: 'A named thought' })])] });
339
- await drain();
340
- const label = labelOf(p.field(), 110);
341
- for (const bit of ['A named thought', 'burning', 'size 240', 'all of it still alive']) {
342
- assert.ok(label.includes(bit), `the aria-label says "${bit}" — got "${label}"`);
187
+ await test('a thought with no work yet says nothing about what survived', async () => {
188
+ const p = await boot({ mine: mine([idea()]) });
189
+ const i = findIdea(p, 1);
190
+ assert.equal(p.SKY().brightnessWord(i), null);
191
+ p.SKY().pick({ kind: 'idea', ref: i }, false);
192
+ assert.doesNotMatch(p.panel(), /still alive/);
193
+ });
194
+
195
+ // ═══ the wells ═══════════════════════════════════════════════════════════════
196
+
197
+ await test('a well is the goal that holds the work, from GET /sky, and a caught thought sits inside it', async () => {
198
+ const p = await boot({ mine: mine([star()]), sky: sky([well()]), versions: versions() });
199
+ const S = p.S(); const g = S.goalById.get('7'); const i = findIdea(p, 10);
200
+ assert.ok(g, 'the goal is a well');
201
+ assert.equal(g.title, 'The well — a goal that holds work');
202
+ assert.ok(Math.hypot(i.x - g.x, i.y - g.y) <= g.r, 'the thought is placed inside the well that caught it');
203
+ assert.ok(g.thoughts.includes(i));
204
+ p.key('l');
205
+ assert.match(p.rows(), /· in The well/, 'and the row names the well');
206
+ });
207
+
208
+ await test('the rings are the criteria, closing as each is satisfied and thick with the credits on them', async () => {
209
+ const p = await boot({ sky: sky([well()]) });
210
+ const g = p.S().goalById.get('7');
211
+ assert.equal(g.crit.length, 2);
212
+ same(g.crit.map((c) => c.satisfied), [true, false]);
213
+ same(g.crit.map((c) => c.credits), [60, 70], 'a ring\'s credits are the credits of the tasks on it');
214
+ assert.equal(g.tasks.find((t) => t.id === '500').ci, 0, 'a task knows which ring it gates');
215
+ assert.equal(g.tasks.find((t) => t.id === '500').sourceRef, '10', 'and which thought it was born of');
216
+ });
217
+
218
+ await test('a private goal keeps its structure and none of its prose', async () => {
219
+ const p = await boot({ sky: sky([well({ goal_id: '8', title: null, subtitle: null, criteria: [{ id: '80', criterion_id: 'c1', criterion_md: null, satisfied: false, pending_review: false, task_ids: [] }], tasks: [{ id: '800', title: null, status: 'ready', credits_reward: 10, created_on: '2026-08-01', idea_id: null }] })]) });
220
+ const g = p.S().goalById.get('8');
221
+ assert.equal(g.title, 'A private goal');
222
+ assert.equal(g.crit.length, 1, 'the ring count is structure');
223
+ assert.equal(g.tasks.length, 1);
224
+ });
225
+
226
+ await test('a satellite is drawn on the thought it was folded into', async () => {
227
+ const p = await boot({ mine: mine([star(), satellite()]), sky: sky([well()]) });
228
+ const host = findIdea(p, 10); const sat = findIdea(p, 13);
229
+ assert.equal(sat.host, host);
230
+ assert.ok(Math.abs(Math.hypot(sat.x - host.x, sat.y - host.y) - 14) < 0.001, 'a fixed step off its host, not a slot of its own');
231
+ });
232
+
233
+ await test('a protostar in a well says a nod is waiting; one in open space says nothing claimed it', async () => {
234
+ const p = await boot({ mine: mine([waiting(), idea()]), sky: sky([well()]) });
235
+ const SKY = p.SKY();
236
+ assert.equal(SKY.positionWord(findIdea(p, 14)), 'waiting for a nod');
237
+ assert.equal(SKY.positionWord(findIdea(p, 1)), 'nothing has claimed it');
238
+ assert.equal(SKY.positionWord(findIdea(p, 14)), 'waiting for a nod');
239
+ });
240
+
241
+ // ═══ the drawer — never the only path ════════════════════════════════════════
242
+
243
+ await test('EVERY DRAWN THOUGHT IS A ROW IN THE DRAWER with its whole sentence', async () => {
244
+ const p = await boot({ mine: mine([...FIVE(), waiting()]), sky: sky([well()]) });
245
+ p.key('l');
246
+ const rows = p.rows(); const SKY = p.SKY();
247
+ for (const i of p.S().ideas) {
248
+ assert.ok(rows.includes(`data-id="${i.id}"`), `${i.title} has a row`);
249
+ assert.ok(rows.includes(SKY.esc(i.title)), `${i.title} is named whole`);
250
+ assert.ok(rows.includes(SKY.esc(SKY.became(i))), `${i.title} says what it became`);
251
+ assert.ok(rows.includes(i.well ? '· in The well' : '· open space'), `${i.title} says where it sits`);
252
+ if (i.size && i.size.credits) assert.ok(rows.includes(`size ${i.size.credits}`), `${i.title} says its size`);
343
253
  }
344
- // And it is a real control, not a div with a handler.
345
- assert.match(p.field(), /<button type="button" class="sky-body"/);
346
- });
347
-
348
- await test('the page points at the list before anything is picked, and the panel links out after', async () => {
349
- const p = bootSkyPage({ pages: [page([star()])], goals: [{ id: '7', title: 'A goal' }] });
350
- await drain();
351
- assert.match(p.panel(), /href="\/thinking"/, 'the unpicked panel names the room');
352
- p.pick(10);
353
- const html = p.panel();
354
- assert.match(html, /href="\/idea\/10"/, 'the record');
355
- assert.match(html, /href="\/goals#\/goal\/7"/, 'the well');
356
- assert.match(html, /href="\/task\/500"/, 'and the work it became');
357
- });
358
-
359
- await test('the shell markup itself carries the way back to the list', async () => {
360
- const html = await readFile('modules/hall-ui/public/sky.html');
361
- assert.match(html, /href="\/builders\/thinking"/);
362
- // …and the room carries the way here, so the pair is reachable both ways.
363
- const room = await readFile('modules/hall-ui/public/thinking.html');
364
- assert.match(room, /href="\/builders\/sky"/);
365
- });
366
-
367
- // ═══ the read ═══════════════════════════════════════════════════════════════
368
-
369
- await test('it reads the VIEWER own trail — the id comes from /me, never from the URL', async () => {
370
- const p = bootSkyPage({ pages: [page([star()])], viewer: { id: 4242 } });
371
- await drain();
372
- assert.ok(p.requests.some((u) => u.includes('/inbox/by-builder/4242')));
373
- assert.ok(!p.requests.some((u) => /builder=/.test(u)), 'no builder query parameter anywhere');
374
- });
375
-
376
- await test('the whole trail is read in the background, and the figures count what arrived', async () => {
377
- const first = page([star({ id: 200 }), star({ id: 201 })], { total: 3, offset: 0 });
378
- const second = page([remnant({ id: 202 })], { total: 3, offset: 2 });
379
- const p = bootSkyPage({ pages: [first, second] });
380
- await drain(40);
381
- assert.equal(p.requests.filter((u) => u.includes('/inbox/by-builder/')).length, 2);
382
- assert.match(p.sub(), /2 burning, 1 burned out, 3 in all/);
383
- });
384
-
385
- await test('the wells are read AFTER the field is on screen, so the picture never waits on them', async () => {
386
- const p = bootSkyPage({ pages: [page([star()])], goals: [{ id: '7', title: 'A goal' }] });
387
- await drain();
388
- const trailAt = p.requests.findIndex((u) => u.includes('/inbox/by-builder/'));
389
- const goalsAt = p.requests.findIndex((u) => /\/goals$/.test(u));
390
- assert.ok(trailAt >= 0 && goalsAt > trailAt, 'the trail is asked for first');
391
- });
392
-
393
- await test('an empty trail says so, and points at the one gesture that fixes it', async () => {
394
- const p = bootSkyPage({ pages: [page([])] });
395
- await drain();
396
- assert.match(p.field(), /nothing in your sky/);
397
- assert.match(p.field(), /href="\/ideas"/);
398
- });
399
-
400
- await test('a trail that could not be read says so rather than drawing an empty sky', async () => {
401
- const p = bootSkyPage({ pages: [page([star()])], trailStatus: 500 });
402
- await drain();
403
- assert.match(p.field(), /Could not read your idea trail/);
404
- assert.ok(!/sky-body/.test(p.field()));
405
- });
406
-
407
- // ═══ it derives nothing the server already answered ═════════════════════════
408
-
409
- await test('when the status and the resolved state disagree, the page draws the STATE', async () => {
410
- // The room's rule, and it matters more here: a state decided twice is two
411
- // states, and the sky is the surface most tempted to guess because it draws
412
- // rather than lists. `status` may only be read for what a thought BECAME.
413
- //
414
- // A GREP CANNOT ASK THIS. `headline()` legitimately counts `state === 'star'`
415
- // — the server's own vocabulary, used as the server emitted it — so banning
416
- // the five words from the source would fail a correct page while still
417
- // passing one that re-derived the object from something else entirely. So the
418
- // question is put as a disagreement the page has to resolve: a row whose
419
- // `status` says it was discarded and whose resolver says it is a burning
420
- // star. Anything reading the status draws a cold body.
421
- const liar = star({
422
- id: 300, title: 'Discarded, and still burning', status: 'discarded', triage_note: 'a duplicate',
423
- });
424
- const p = bootSkyPage({ pages: [page([liar])] });
425
- await drain();
426
- assert.equal(glyphOf(p.field(), 300).object, 'star', 'the resolver wins');
427
- assert.match(labelOf(p.field(), 300), /burning/);
428
- assert.ok(!/declined/.test(labelOf(p.field(), 300)));
429
- // …and the other direction: a row the resolver calls a cold body draws one
430
- // however alive its own columns look.
431
- const other = star({
432
- id: 301, title: 'Promoted, and never lit', status: 'promoted',
433
- state: { state: 'cold-body', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: true },
434
- });
435
- const q = bootSkyPage({ pages: [page([other])] });
436
- await drain();
437
- assert.equal(glyphOf(q.field(), 301).object, 'cold-body');
254
+ assert.equal(p.el('roomCount').textContent, '6 of 6');
438
255
  });
439
256
 
440
- await test('a state the page has never heard of is drawn through, not swallowed', async () => {
441
- // idea-objects.js passes an unknown state through rather than defaulting it,
442
- // because a sixth object would be a real answer the server gave. Drawing it
443
- // unstyled beats drawing nothing, and beats drawing it as a protostar.
444
- const sixth = star({ id: 310, state: { ...star().state, state: 'pulsar' } });
445
- const p = bootSkyPage({ pages: [page([sixth])] });
446
- await drain();
447
- assert.equal(glyphOf(p.field(), 310).object, 'pulsar');
448
- assert.match(labelOf(p.field(), 310), /pulsar/);
257
+ await test('the drawer is one key away — L opens it, L closes it, and the button agrees', async () => {
258
+ const p = await boot({ mine: mine(FIVE()) });
259
+ const room = p.el('room');
260
+ assert.equal(room.hidden, true, 'closed at first');
261
+ p.key('l');
262
+ assert.equal(room.hidden, false);
263
+ assert.equal(p.el('roomBtn').getAttribute('aria-pressed'), 'true');
264
+ assert.match(p.rows(), /class="row"/);
265
+ p.key('l');
266
+ assert.equal(room.hidden, true);
267
+ assert.equal(p.el('roomBtn').getAttribute('aria-pressed'), 'false');
268
+ });
269
+
270
+ await test('a key pressed inside the composer is the composer\'s, not the sky\'s', async () => {
271
+ const p = await boot({ mine: mine(FIVE()) });
272
+ p.key('l', { tagName: 'INPUT', blur() {} });
273
+ assert.equal(p.el('room').hidden, true, 'typing an l into the line does not open the drawer');
274
+ });
275
+
276
+ // ═══ empty and failed ════════════════════════════════════════════════════════
277
+
278
+ await test('THE PAGE RENDERS WITH AN EMPTY TRAIL — an empty sky is a state, not an error', async () => {
279
+ const p = await boot({ mine: mine([]), sky: sky([]), versions: versions() });
280
+ assert.ok(p.ready());
281
+ assert.match(p.stats(), /<b>0<\/b> thoughts/);
282
+ p.key('l');
283
+ assert.match(p.rows(), /Nothing of that kind in your sky\./);
284
+ assert.doesNotThrow(() => p.frame(), 'a frame draws with nothing in it');
285
+ assert.equal(p.S().BANDS.length, 2, 'the bands are still the versions\'');
286
+ });
287
+
288
+ await test('reads that fail still leave a sky that draws', async () => {
289
+ const p = await boot({ status: { sky: 500, mine: 500 } });
290
+ assert.ok(p.ready());
291
+ assert.match(p.stats(), /<b>0<\/b> thoughts/);
292
+ assert.doesNotThrow(() => p.frame());
293
+ assert.equal(p.S().BANDS[0].r0, 0, 'with no versions read there is still a present band to draw in');
294
+ });
295
+
296
+ // ═══ the two heuristics that must not ship (part 5's facts) ══════════════════
297
+
298
+ await test('the claude layer is HIDDEN until filed_via exists, and every thought shows', async () => {
299
+ const p = await boot({ mine: mine(FIVE()) });
300
+ assert.equal(p.S().hasFiledVia, false);
301
+ assert.equal(p.el('claudeBtn').hidden, true, 'the pill is not offered');
302
+ assert.equal(p.S().ideas.filter(p.SKY().inLayer).length, 5, 'nothing is guessed to be a session\'s');
303
+ assert.ok(p.S().ideas.every((i) => i.origin === 'human'));
304
+ });
305
+
306
+ await test('once filed_via is on a row the pill appears, that thought waits behind it, and historic rows still show', async () => {
307
+ const p = await boot({ mine: mine([star(), idea({ id: 2, title: 'A session filed this', filed_via: 'session' }), idea({ id: 3, title: 'A person filed this', filed_via: 'person' })]) });
308
+ assert.equal(p.S().hasFiledVia, true);
309
+ assert.equal(p.el('claudeBtn').hidden, false);
310
+ const SKY = p.SKY();
311
+ assert.equal(SKY.inLayer(findIdea(p, 2)), false, 'off by default: only what a person filed');
312
+ assert.equal(SKY.inLayer(findIdea(p, 10)), true, 'a historic row with no filed_via shows (the owner\'s rule)');
313
+ assert.equal(SKY.inLayer(findIdea(p, 3)), true);
314
+ p.click('claudeBtn');
315
+ assert.equal(SKY.inLayer(findIdea(p, 2)), true, 'the pill reveals it');
316
+ });
317
+
318
+ await test('NO BORN-OF MARK IS DRAWN — the read does not exist yet, so nothing is guessed', async () => {
319
+ const p = await boot({ mine: mine(FIVE()), sky: sky([well()]) });
320
+ const S = p.S();
321
+ assert.equal(S.becameGoal.size, 0);
322
+ assert.equal(S.becameCrit.size, 0);
323
+ assert.ok(S.ideas.every((i) => i.shaped === null));
324
+ assert.ok(S.goals.every((g) => g.bornOf === null && g.crit.every((c) => c.bornOf === null)));
325
+ p.key('l');
326
+ assert.doesNotMatch(p.rows(), /born of a thought/);
327
+ });
328
+
329
+ // ═══ the bands — derived, never named by hand ════════════════════════════════
330
+
331
+ await test('the bands come from the versions read: building at the centre, shipped outward, small ones sharing a band', async () => {
332
+ const p = await boot({});
333
+ const goalsIn = (v, n) => Array.from({ length: n }, (_, i) => ({ version: v, id: `${v}-${i}` }));
334
+ const bands = p.SKY().bandsFor([
335
+ { id: 'NEW', status: 'building', shipped_at: null },
336
+ { id: 'OLD-1', status: 'shipped', shipped_at: '2026-06-01T00:00:00.000Z' },
337
+ { id: 'OLD-2', status: 'shipped', shipped_at: '2026-04-01T00:00:00.000Z' },
338
+ { id: 'OLD-3', status: 'shipped', shipped_at: '2026-03-01T00:00:00.000Z' },
339
+ { id: 'PLAN', status: 'planning', shipped_at: null },
340
+ ], [...goalsIn('NEW', 4), ...goalsIn('OLD-1', 12), ...goalsIn('OLD-2', 1), ...goalsIn('OLD-3', 2)]);
341
+ same(bands[0].versions, ['NEW', 'PLAN'], 'the present holds whatever is not shipped');
342
+ assert.equal(bands[0].r0, 0);
343
+ same(bands[1].versions, ['OLD-1'], 'the newest shipped version is the first ring out');
344
+ same(bands[2].versions, ['OLD-2', 'OLD-3'], 'two small versions share a band so no ring is razor-thin');
345
+ assert.equal(bands[2].state, 'the beginning');
346
+ assert.equal(bands[1].label, 'old-1');
347
+ assert.ok(bands[1].r0 > bands[0].r1 && bands[2].r0 > bands[1].r1, 'rings never overlap');
348
+ const src = ['sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js'];
349
+ const fs = await import('node:fs'); const path = await import('node:path');
350
+ for (const f of src) assert.doesNotMatch(fs.readFileSync(path.join('modules/hall-ui/public', f), 'utf8'), /BONGOS-V\d|GDS-V\d|PMS-V\d|CB-V\d/, `${f} names no instance version`);
351
+ });
352
+
353
+ await test('"the whole project" leaves for the project sky at /goal-map', async () => {
354
+ const p = await boot({ mine: mine(FIVE()) });
355
+ p.click('lensAll');
356
+ assert.equal(p.navigated, '/goal-map');
357
+ assert.equal(p.S().lens, 'mine', 'this page does not switch — part 4 owns that view');
358
+ });
359
+
360
+ // ═══ the words are never markup ══════════════════════════════════════════════
361
+
362
+ await test('a title never becomes markup — in the drawer, the panel and the hover card', async () => {
363
+ const evil = '<img src=x onerror=alert(1)> & "quotes"';
364
+ const p = await boot({ mine: mine([star({ title: evil })]), sky: sky([well({ title: '<b>bold goal</b>' })]) });
365
+ p.key('l');
366
+ const i = findIdea(p, 10);
367
+ p.SKY().pick({ kind: 'idea', ref: i }, false);
368
+ p.SKY().showCard({ kind: 'idea', ref: i }, 10, 10);
369
+ for (const [where, html] of [['rows', p.rows()], ['panel', p.panel()], ['card', p.card()]]) {
370
+ assert.ok(html.includes('&lt;img src=x onerror=alert(1)&gt; &amp; &quot;quotes&quot;'), `${where} escapes the title`);
371
+ assert.ok(!html.includes('<img'), `${where} carries no element from the title`);
372
+ }
373
+ assert.ok(p.rows().includes('&lt;b&gt;bold goal') && !p.rows().includes('<b>bold goal'), 'a goal title is escaped too');
374
+ });
375
+
376
+ // ═══ the tour, the lenses and the keys ═══════════════════════════════════════
377
+
378
+ await test('the tour is the template\'s thirteen steps — ? opens it, Escape closes it', async () => {
379
+ const p = await boot({ mine: mine(FIVE()), sky: sky([well()]), versions: versions() });
380
+ const STEPS = p.SKY().STEPS;
381
+ assert.equal(STEPS.length, 13);
382
+ same(STEPS.map((s) => s.h), [
383
+ 'This is your sky.', 'A goal is a gravity well.', 'A ring is a criterion.', 'A burning thought is a star.', 'Some thoughts became the structure.',
384
+ 'Unclaimed thoughts drift.', 'An alive well glows.', 'Routes run between goals that wait on each other.', 'A settled system is a goal that finished.',
385
+ 'The sky has a clock.', 'By default, you explore.', 'Release a thought here.', 'When it is too much, go simple.',
386
+ ]);
387
+ assert.equal(p.el('tour').hidden, true);
388
+ p.key('?');
389
+ assert.equal(p.el('tour').hidden, false);
390
+ assert.equal(p.el('tourN').textContent, 'how to read the sky · 1 of 13');
391
+ for (let n = 0; n < 12; n++) p.click('tourNext');
392
+ assert.equal(p.el('tourN').textContent, 'how to read the sky · 13 of 13');
393
+ assert.equal(p.el('tourNext').textContent, 'start reading');
394
+ p.key('Escape');
395
+ assert.equal(p.el('tour').hidden, true);
396
+ assert.equal(p.S().lens, 'mine', 'the tour hands the sky back to your thinking');
397
+ });
398
+
399
+ await test('the tour opens by itself on a first visit, and only then', async () => {
400
+ const first = await boot({ mine: mine(FIVE()), tourSeen: false });
401
+ assert.equal(first.timers.pending(), 1, 'a first visit schedules the tour');
402
+ await first.timers.flush();
403
+ assert.equal(first.el('tour').hidden, false);
404
+ const again = await boot({ mine: mine(FIVE()), tourSeen: true });
405
+ assert.equal(again.timers.pending(), 0);
406
+ });
407
+
408
+ await test('the simple lens: no drawer, no jump, no legend, just the thoughts in their wells', async () => {
409
+ const p = await boot({ mine: mine(FIVE()), sky: sky([well()]) });
410
+ p.key('l');
411
+ p.click('lensSimple');
412
+ assert.equal(p.S().lens, 'simple');
413
+ assert.equal(p.el('room').hidden, true, 'the drawer closes');
414
+ assert.equal(p.el('roomBtn').hidden, true);
415
+ assert.equal(p.el('legend').hidden, true);
416
+ assert.equal(p.el('recentBtn').hidden, false, 'recent / all is the one control');
417
+ assert.equal(p.el('lensWord').textContent, '— simply');
418
+ p.click('recentBtn');
419
+ assert.equal(p.S().recentOnly, true);
420
+ assert.match(p.stats(), /the last thirty days/);
421
+ });
422
+
423
+ await test('keys: 0 fits the sky and / puts the cursor in the composer', async () => {
424
+ const p = await boot({ mine: mine(FIVE()) });
425
+ p.S().cam.k = 5;
426
+ p.key('0');
427
+ assert.notEqual(p.S().cam.k, 5, 'the camera moved to the fit');
428
+ p.key('/');
429
+ assert.equal(p.focused(), p.el('thought'));
430
+ });
431
+
432
+ await test('"my influence" fades every well your thoughts never touched', async () => {
433
+ const p = await boot({ mine: mine([star()]), sky: sky([well(), well({ goal_id: '9', title: 'Untouched', tasks: [{ id: '900', title: 'x', status: 'ready', credits_reward: 10, created_on: '2026-08-01', idea_id: null }] })]) });
434
+ p.click('influenceBtn');
435
+ const SKY = p.SKY(); const now = Date.now();
436
+ assert.equal(SKY.goalAlpha(p.S().goalById.get('7'), now), 1);
437
+ assert.ok(SKY.goalAlpha(p.S().goalById.get('9'), now) <= 0.12);
449
438
  });
450
439
 
451
- await test('it computes no brightness and no compression of its own', async () => {
452
- const src = await readFile('modules/hall-ui/public/sky.js');
453
- const code = src.split('\n').filter((l) => !/^\s*\/\//.test(l)).join('\n');
454
- assert.ok(!/Math\.log/.test(code), 'no second compression curve');
455
- assert.ok(!/descent_tasks\s*\.\s*length\s*\/\s*/.test(code), 'no brightness computed from a descent length');
440
+ await test('a capped trail is spoken, never a silent slice', async () => {
441
+ const p = await boot({ mine: mine(FIVE(), { total: 1400, truncated: true }) });
442
+ assert.match(p.stats(), /the newest 5 only/);
456
443
  });
457
444
 
445
+ console.log('');
458
446
  summary();