@bongos/core 1.19.1011 → 1.19.1013

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 (36) hide show
  1. package/.bongos-core.json +53 -43
  2. package/docs/adr/0331-the-sky-is-the-ideators-home.md +6 -1
  3. package/docs/copy-inventory.md +99 -56
  4. package/docs/copy-registry.json +830 -465
  5. package/docs/file-map.md +1 -1
  6. package/docs/module-api-changelog.md +4 -0
  7. package/modules/hall-ui/public/idea-objects.js +4 -1
  8. package/modules/hall-ui/public/sky-composer.js +155 -0
  9. package/modules/hall-ui/public/sky-data.js +287 -0
  10. package/modules/hall-ui/public/sky-draw.js +244 -0
  11. package/modules/hall-ui/public/sky-interaction.js +137 -0
  12. package/modules/hall-ui/public/sky-layout.js +81 -0
  13. package/modules/hall-ui/public/sky-panel.js +141 -0
  14. package/modules/hall-ui/public/sky-tour.js +80 -0
  15. package/modules/hall-ui/public/thinking.css +203 -267
  16. package/modules/hall-ui/public/thinking.html +119 -58
  17. package/modules/hall-ui/public/thinking.states.json +33 -9
  18. package/package-lock.json +2 -2
  19. package/package.json +1 -1
  20. package/scripts/gds/gen-file-map.js +29 -12
  21. package/src/bongos/routes/me.js +4 -1
  22. package/src/bongos/serve-internal.js +20 -6
  23. package/src/module-api.js +1 -1
  24. package/tests/file_map.mjs +35 -3
  25. package/tests/hall_audit.mjs +27 -0
  26. package/tests/hall_page_gate_map.mjs +11 -5
  27. package/tests/hall_sky.mjs +393 -405
  28. package/tests/hall_thinking_room.mjs +274 -296
  29. package/tests/helpers.mjs +185 -217
  30. package/tests/ideator_large_dead_thought_proof.mjs +33 -25
  31. package/tests/ideator_traceable_proof.mjs +61 -39
  32. package/modules/hall-ui/public/sky.css +0 -365
  33. package/modules/hall-ui/public/sky.html +0 -79
  34. package/modules/hall-ui/public/sky.js +0 -580
  35. package/modules/hall-ui/public/sky.states.json +0 -20
  36. package/modules/hall-ui/public/thinking.js +0 -418
@@ -1,366 +1,344 @@
1
- // tests/hall_thinking_room.mjs — THE ROOM, at the rendered layer
2
- // (task 1004098 / BV2.R03 of goal 1000095, criterion `ideator-nothing-is-lost`).
1
+ // tests/hall_thinking_room.mjs — THE ROOM inside the sky, and the composer
2
+ // (task 1004231 / sky part 3 of 5, goal 1000095; ADR 0331; criterion
3
+ // `ideator-nothing-is-lost`).
3
4
  //
4
- // This suite boots the REAL modules/hall-ui/public/thinking.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
+ // The room (task 1004098) was a page; it is now the sky's list drawer, one key
6
+ // away, and this suite is what its old suite was for: the last step, where a
7
+ // correct server answer becomes a sentence a person reads. It boots the REAL
8
+ // page through bootSkyPage (tests/helpers.mjs) and reads the drawer it
9
+ // writes; the drawing half is tests/hall_sky.mjs.
9
10
  //
10
- // WHAT IT IS ACTUALLY GUARDING. Every number this page draws is computed and
11
- // tested server-side already — the five states (tests/idea_state.mjs), size
12
- // (tests/idea_size.mjs), brightness (tests/idea_brightness.mjs), the descent
13
- // (tests/idea_descent_surface.mjs). What none of those can see is the last
14
- // step, where a correct answer is turned into a sentence: a renderer that
15
- // collapses "we could not read the removal facts" into "none of it survived",
16
- // or that computes its own compression curve, or that counts its figures over
17
- // one window while labelling them as a life's work, fails nothing upstream. So
18
- // the cases below are mostly about the THREE ANSWERS staying three, and about
19
- // the page never deriving what the server already decided.
11
+ // THE COMPOSER is the other half here, because the task's binding line is
12
+ // that filing from the sky writes the SAME record the hall form writes: quick
13
+ // is POST /inbox with the line, full is POST /inbox with the template's fields
14
+ // — so a case asserts the request body byte for byte — and gravity is the real
15
+ // landing verdict (POST /inbox/preview-landing over the suggester's pick),
16
+ // never a keyword match, so a case makes the two disagree and checks which
17
+ // one the page believed.
20
18
 
21
- import { makeRunner, settle } from './helpers.mjs';
22
- import { bootThinkingPage } from './helpers.mjs';
19
+ import assert from 'node:assert/strict';
20
+ import { makeRunner, settle, bootSkyPage } from './helpers.mjs';
23
21
 
24
22
  const { test, summary } = makeRunner();
23
+ // Values born inside the vm carry that realm's prototypes, so a strict deepEqual
24
+ // reports "same structure but not reference-equal"; compare them as plain data.
25
+ const same = (actual, expected, msg) => assert.deepEqual(JSON.parse(JSON.stringify(actual)), expected, msg);
25
26
 
26
- // Several awaits deep (the /me read, then a page, then the background loop),
27
- // so one microtask turn is not enough. Ten is comfortably past the longest
28
- // chain any case below sets up.
29
- const drain = async (n = 10) => { for (let i = 0; i < n; i += 1) await settle(); };
27
+ const drain = async (n = 30) => { for (let i = 0; i < n; i += 1) await settle(); };
28
+ const boot = async (opts = {}) => { const p = bootSkyPage(opts); await drain(); return p; };
30
29
 
31
- // A trail row in the shape the route emits. Defaults are the dullest possible
32
- // thought — filed, nothing came of it — so every case below states only the
33
- // facts it is actually about.
34
30
  function idea(over = {}) {
35
31
  return {
36
- id: 1,
37
- title: 'A thought',
38
- kind: 'feature',
39
- status: 'open',
40
- captured_at: '2026-09-01T00:00:00.000Z',
41
- triage_note: null,
42
- promoted_to_task_id: null,
43
- promoted_task_title: null,
44
- promoted_task_status: null,
45
- merged_into_idea_id: null,
46
- merged_into_idea_title: null,
47
- lineage_tasks: [],
48
- descent_tasks: [],
32
+ id: 1, title: 'A thought', kind: 'feature', status: 'open', captured_at: '2026-09-01T00:00:00.000Z',
33
+ triage_note: null, promoted_to_task_id: null, promoted_task_title: null, promoted_task_status: null,
34
+ merged_into_idea_id: null, merged_into_idea_title: null, grade: 'quick', field_values: null,
35
+ lineage_tasks: [], descent_tasks: [],
49
36
  size: { credits: 0, payments: 0, scale: 0, historical: false },
50
- state: {
51
- state: 'protostar', position: 'open-space', wellGoalId: null,
52
- awaitingNod: false, brightness: null, hasWork: false, attributable: true,
53
- },
37
+ state: { state: 'protostar', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: true },
54
38
  ...over,
55
39
  };
56
40
  }
57
- const page = (ideas, over = {}) => ({ ideas, total: ideas.length, offset: 0, limit: 100, ...over });
58
-
59
41
  const star = (over = {}) => idea({
60
- id: 10, title: 'It ignited', status: 'promoted',
42
+ id: 10, title: 'It ignited', status: 'promoted', captured_at: '2026-09-05T00:00:00.000Z',
61
43
  lineage_tasks: [{ task_id: 500, origin: 'promote', title: 'The task', status: 'shipped', goal_id: 7 }],
44
+ descent_tasks: [{ task_id: 501, depth: 1, relation: 'split', title: 'A follow-up', status: 'active', goal_id: 7 }],
62
45
  size: { credits: 240, payments: 4, scale: 0.86, historical: false },
63
- state: { state: 'star', position: 'well', wellGoalId: 7, awaitingNod: false, brightness: 1, hasWork: true, attributable: true },
46
+ state: { state: 'star', position: 'well', wellGoalId: '7', awaitingNod: false, brightness: 1, hasWork: true, attributable: true },
64
47
  ...over,
65
48
  });
66
- const remnant = (over = {}) => star({
67
- id: 11, title: 'It burned, then it went',
68
- size: { credits: 180, payments: 5, scale: 0.81, historical: false },
69
- state: { state: 'remnant', position: 'well', wellGoalId: 7, awaitingNod: false, brightness: 0, hasWork: true, attributable: true },
49
+ const remnant = (over = {}) => star({ id: 11, title: 'It burned, then it went', descent_tasks: [], size: { credits: 180, payments: 5, scale: 0.81, historical: true }, state: { state: 'remnant', position: 'well', wellGoalId: '7', awaitingNod: false, brightness: 0, hasWork: true, attributable: true }, ...over });
50
+ const satellite = (over = {}) => idea({ id: 13, title: 'Folded', status: 'merged', merged_into_idea_id: 10, merged_into_idea_title: 'It ignited', state: { state: 'satellite', position: 'well', wellGoalId: '7', awaitingNod: false, brightness: 1, hasWork: true, attributable: true }, ...over });
51
+ const coldBody = (over = {}) => idea({ id: 12, title: 'It never ignited', status: 'discarded', state: { state: 'cold-body', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: true }, ...over });
52
+ const waiting = (over = {}) => idea({
53
+ id: 14, title: 'Caught, waiting for a nod', status: 'promoted',
54
+ lineage_tasks: [{ task_id: 9001, origin: 'route', title: 'Caught, waiting for a nod', status: 'backlog', goal_id: 7 }],
55
+ state: { state: 'protostar', position: 'well', wellGoalId: '7', awaitingNod: true, brightness: 1, hasWork: false, attributable: true },
70
56
  ...over,
71
57
  });
72
-
73
- // ---------------------------------------------------------------------------
74
- // The five objects reach the reader as the design's words
75
-
76
- await test('every one of the five objects renders its own label from the shared table', async () => {
77
- const rows = [
78
- star(),
79
- idea({ id: 2, state: { state: 'protostar', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: true } }),
80
- idea({ id: 3, status: 'merged', merged_into_idea_id: 9, merged_into_idea_title: 'The other one', state: { state: 'satellite', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: 1, hasWork: true, attributable: true } }),
81
- remnant(),
82
- idea({ id: 5, status: 'discarded', state: { state: 'cold-body', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: false } }),
83
- ];
84
- const p = bootThinkingPage({ pages: [page(rows)] });
85
- await drain();
86
- const html = p.rows();
87
- const objects = p.objects();
88
- for (const key of objects.ORDER) {
89
- const label = objects.objectFor(key).label;
90
- if (!html.includes(`data-object="${key}"`)) throw new Error(`no row marked ${key}`);
91
- if (!html.includes(`>${label}<`)) throw new Error(`${key} never says "${label}"`);
92
- }
58
+ const FIVE = () => [star(), idea(), satellite(), remnant(), coldBody()];
59
+ const mine = (ideas, over = {}) => ({ builder_id: 95, ideas, total: ideas.length, cap: 1000, truncated: false, ...over });
60
+ const well = (over = {}) => ({ goal_id: '7', title: 'The well — a goal', subtitle: null, status: 'open', version_id: 'V2', category: null, task_total: 1, task_done: 1, created_at: '2026-06-01T00:00:00.000Z', criteria: [], tasks: [{ id: '500', title: 'The task', status: 'shipped', credits_reward: 60, created_on: '2026-07-01', idea_id: '10' }], ...over });
61
+ const sky = (goals) => ({ goals, edges: [], counts: {}, cache: {} });
62
+ const versions = () => ({ versions: [{ id: 'V2', status: 'building', shipped_at: null }] });
63
+ const findIdea = (p, id) => p.S().ideas.find((i) => i.id === String(id));
64
+ const bodyOf = (p, method, path) => (p.requests.find((r) => r.method === method && r.url === `/api/bongos${path}`) || {}).body;
65
+ const filed = (over = {}) => ({ status: 201, ok: true, data: { idea: { id: 900, title: 'a thought about the sky', kind: 'feature', status: 'open', captured_at: '2026-09-25T10:00:00.000Z', grade: 'quick', ...over } } });
66
+
67
+ // ═══ the room ════════════════════════════════════════════════════════════════
68
+
69
+ await test('the room lists everything, counts it, and its filters are the five in the shared order with their counts', async () => {
70
+ const p = await boot({ mine: mine(FIVE()), sky: sky([well()]), versions: versions() });
71
+ p.key('l');
72
+ assert.equal(p.el('roomTitle').textContent, 'the room');
73
+ assert.equal(p.el('roomCount').textContent, '5 of 5');
74
+ const f = p.filters();
75
+ assert.match(f, /data-f="all"[^>]*>everything <span[^>]*>5</);
76
+ for (const [k, n] of [['star', 1], ['protostar', 1], ['satellite', 1], ['remnant', 1], ['cold-body', 1]]) assert.match(f, new RegExp(`data-f="${k}"[^>]*>[^<]+<span[^>]*>${n}<`));
77
+ assert.equal((p.rows().match(/class="row"/g) || []).length, 5);
93
78
  });
94
79
 
95
- await test('the page does not re-derive the object — the server\'s state wins over the row\'s status', async () => {
96
- // A discarded row the resolver nevertheless called a star. That combination
97
- // is REAL (idea-state.js step 2: work beats a discard, because triage can
98
- // decline a duplicate whose twin already routed and paid), and a page that
99
- // switched on `status` instead of `state` would draw it as a cold body.
100
- const p = bootThinkingPage({ pages: [page([star({ status: 'discarded' })])] });
101
- await drain();
102
- const html = p.rows();
103
- if (!html.includes('data-object="star"')) throw new Error('the status overrode the state');
104
- if (html.includes('cold-body')) throw new Error('drew a cold body over a star');
80
+ await test('choosing a filter shows only that object, and the drawer says which', async () => {
81
+ const p = await boot({ mine: mine(FIVE()) });
82
+ p.key('l');
83
+ const btn = p.el('filters').querySelectorAll('button').find((b) => b.dataset.f === 'remnant');
84
+ btn.fire('click', { type: 'click' });
85
+ assert.equal((p.rows().match(/class="row"/g) || []).length, 1);
86
+ assert.match(p.rows(), /data-id="11"/);
87
+ assert.equal(p.el('roomCount').textContent, '1 of 5');
88
+ assert.equal(p.S().filter, 'remnant');
105
89
  });
106
90
 
107
- // ---------------------------------------------------------------------------
108
- // Size and brightness are two channels — the whole reason the model has two
109
-
110
- await test('a remnant renders LARGE and DEAD: full size, no colour, and it says so', async () => {
111
- const p = bootThinkingPage({ pages: [page([remnant()])] });
112
- await drain();
113
- const html = p.rows();
114
- if (!html.includes('180')) throw new Error('lost the true figure');
115
- if (!html.includes('--w:81%')) throw new Error('the bar did not keep its size');
116
- if (!html.includes('data-lit="out"')) throw new Error('the bar kept its colour');
117
- if (!html.includes('none of it still alive')) throw new Error('never said the work is gone');
91
+ await test('a row says what became of it, in words a reader can act on', async () => {
92
+ const p = await boot({ mine: mine([...FIVE(), waiting()]), sky: sky([well()]) });
93
+ p.key('l');
94
+ const rows = p.rows();
95
+ const rowOf = (id) => (new RegExp(`data-id="${id}"[\\s\\S]*?</button>`).exec(rows) || [''])[0];
96
+ assert.match(rowOf(10), /2 tasks, 1 shipped/, 'a star counts its roots and its descent, and how many shipped');
97
+ assert.match(rowOf(10), /size 240/);
98
+ assert.match(rowOf(1), /nothing yet/);
99
+ assert.match(rowOf(13), /folded into another thought/);
100
+ assert.match(rowOf(12), /declined at triage/);
101
+ assert.match(rowOf(14), /1 task/);
102
+ assert.match(rowOf(14), /· in The well/);
103
+ assert.match(rowOf(1), /· open space/);
118
104
  });
119
105
 
120
- await test('a thought that never mattered is distinguishable from a large dead one at a glance', async () => {
121
- // The failure the two-channel model exists to prevent: one number would draw
122
- // these two the same. The proof task (BV2.R21) demonstrates it live; this is
123
- // the pin under it.
124
- const p = bootThinkingPage({ pages: [page([remnant(), idea({ id: 20, title: 'Nothing came of it' })])] });
125
- await drain();
126
- const html = p.rows();
127
- if (!html.includes('--w:81%')) throw new Error('the dead thought lost its size');
128
- // The unignited one has no bar at all, only the dash.
129
- const bars = html.match(/tr-size__bar/g) || [];
130
- if (bars.length !== 1) throw new Error(`expected exactly one bar, got ${bars.length}`);
106
+ await test('the panel spells a thought out: gravity, what it became, size, brightness, position, the descent, the record', async () => {
107
+ const p = await boot({ mine: mine([star(), waiting()]), sky: sky([well()]) });
108
+ p.SKY().pick({ kind: 'idea', ref: findIdea(p, 10) }, false);
109
+ const panel = p.panel();
110
+ assert.match(panel, /<h2>It ignited<\/h2>/);
111
+ assert.match(panel, /gravity<\/span><span class="v"><a href="#" data-goal="7">The well — a goal<\/a>/);
112
+ assert.match(panel, /became<\/span><span class="v">2 tasks, 1 shipped/);
113
+ assert.match(panel, /size — credits<\/span><span class="v num">240/);
114
+ assert.match(panel, /brightness — still alive[\s\S]*all of it still alive/);
115
+ assert.match(panel, /the descent — everything that fell out of it/);
116
+ assert.match(panel, /<span class="st">shipped<\/span><span>The task/);
117
+ assert.match(panel, /<span class="st live">active<\/span><span>A follow-up/);
118
+ assert.match(panel, /href="\/idea\/10"[^>]*>the record</, 'the record is one door away');
119
+ p.SKY().pick({ kind: 'idea', ref: findIdea(p, 14) }, false);
120
+ assert.match(p.panel(), /position<\/span><span class="v">waiting for a nod/);
131
121
  });
132
122
 
133
- await test('an UNREADABLE brightness is not a dead one', async () => {
134
- // attributable:false with work — the lifecycle module could not be asked.
135
- // idea-state.js is emphatic that this is not `share === 0`, and a renderer
136
- // that dimmed the bar or said "none of it still alive" would state, in a
137
- // sentence a person reads, something nobody checked.
138
- const p = bootThinkingPage({
139
- pages: [page([star({ state: { state: 'star', position: 'well', wellGoalId: 7, awaitingNod: false, brightness: null, hasWork: true, attributable: false } })])],
140
- });
141
- await drain();
142
- const html = p.rows();
143
- if (!html.includes('still alive — unknown')) throw new Error('did not say the share is unknown');
144
- if (html.includes('none of it still alive')) throw new Error('called an unreadable share a dead one');
145
- if (html.includes('data-lit="out"')) throw new Error('greyed a bar on a share nobody could read');
123
+ await test('a title is escaped before it reaches the room', async () => {
124
+ const p = await boot({ mine: mine([idea({ title: '<script>alert(1)</script>' })]) });
125
+ p.key('l');
126
+ assert.ok(p.rows().includes('&lt;script&gt;alert(1)&lt;/script&gt;'));
127
+ assert.ok(!p.rows().includes('<script>'));
146
128
  });
147
129
 
148
- await test('a graded brightness reaches the reader as a percentage', async () => {
149
- const p = bootThinkingPage({
150
- pages: [page([star({ state: { state: 'star', position: 'well', wellGoalId: 7, awaitingNod: false, brightness: 0.67, hasWork: true, attributable: true } })])],
151
- });
152
- await drain();
153
- if (!p.rows().includes('67% still alive')) throw new Error('lost the graded share');
130
+ await test('the figures count the five states over the whole trail', async () => {
131
+ const p = await boot({ mine: mine([...FIVE(), star({ id: 15, title: 'Another star' })]) });
132
+ assert.match(p.stats(), /<b>6<\/b> thoughts · <b>2<\/b> burning · <b>1<\/b> waiting · <b>1<\/b> folded in · <b>1<\/b> burned out · <b>1<\/b> declined/);
154
133
  });
155
134
 
156
- await test('`size: null` reads as unknown, never as zero', async () => {
157
- // trail-size.js sets null when the reward port could not be asked at all.
158
- // Drawing that as a dash would claim the thought earned nothing.
159
- const p = bootThinkingPage({ pages: [page([star({ size: null })])] });
160
- await drain();
161
- const html = p.rows();
162
- if (!html.includes('unknown')) throw new Error('did not say the ledger could not be read');
163
- if (html.includes('tr-size__bar')) throw new Error('drew a bar over a size nobody could read');
135
+ await test('an empty trail renders the room empty, and the composer is there to fix it', async () => {
136
+ const p = await boot({ mine: mine([]) });
137
+ p.key('l');
138
+ assert.match(p.rows(), /Nothing of that kind in your sky\./);
139
+ assert.equal(p.el('roomCount').textContent, '0 of 0');
140
+ assert.equal(p.el('composerWrap').hidden, false);
141
+ assert.equal(p.el('thought').placeholder, 'release a thought — one line');
164
142
  });
165
143
 
166
- await test('a zero size is a dash, and a historical one carries its marker', async () => {
167
- const p = bootThinkingPage({
168
- pages: [page([
169
- idea({ id: 30 }),
170
- star({ id: 31, size: { credits: 96, payments: 3, scale: 0.71, historical: true } }),
171
- ])],
172
- });
173
- await drain();
174
- const html = p.rows();
175
- if (!html.includes('—')) throw new Error('a zero size drew no dash');
176
- if (!html.includes('part historical')) throw new Error('a historical size did not say so');
177
- });
144
+ // ═══ the composer — quick ═══════════════════════════════════════════════════
178
145
 
179
- await test('the bar width is the server\'s compressed scale, not a second curve', async () => {
180
- const p = bootThinkingPage({ pages: [page([star({ size: { credits: 9999, payments: 40, scale: 0.42, historical: false } })])] });
146
+ await test('QUICK: Enter files the line through POST /inbox exactly as the hall form does', async () => {
147
+ const p = await boot({ mine: mine(FIVE()), versions: versions() });
148
+ p.handlers.push((m, u) => (m === 'POST' && u.endsWith('/inbox') ? filed() : null));
149
+ p.type('a thought about the sky');
150
+ assert.ok(p.el('file').classList.contains('live'), 'the file button lights as you type');
151
+ p.submit();
181
152
  await drain();
182
- if (!p.rows().includes('--w:42%')) throw new Error('the page compressed the number itself');
153
+ same(bodyOf(p, 'POST', '/inbox'), { title: 'a thought about the sky', body_md: '', kind: 'feature' }, 'the hall form\'s quick body — title, body_md, kind — and nothing else');
154
+ assert.equal(p.el('thought').value, '', 'the line is released');
155
+ assert.match(p.receipt(), /^filed · nothing caught it · floating in open space$/);
156
+ const i = findIdea(p, 900);
157
+ assert.ok(i, 'the filed thought is in the sky');
158
+ assert.equal(i.st, 'protostar');
159
+ assert.equal(i.well, null);
160
+ assert.ok(p.S().flying && p.S().flying.idea === i, 'and it flies to where it landed');
161
+ p.key('l');
162
+ assert.match(p.rows(), /data-id="900"[\s\S]*?a thought about the sky/, 'and it is a row in the drawer at once');
183
163
  });
184
164
 
185
- // ---------------------------------------------------------------------------
186
- // What a row says it became
187
-
188
- await test('a folded thought names the thought that absorbed it, and links to it', async () => {
189
- const p = bootThinkingPage({
190
- pages: [page([idea({ id: 3, status: 'merged', merged_into_idea_id: 9, merged_into_idea_title: 'The other one', state: { state: 'satellite', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: 1, hasWork: true, attributable: true } })])],
191
- });
165
+ await test('an empty line files nothing', async () => {
166
+ const p = await boot({ mine: mine([]) });
167
+ p.type(' ');
168
+ p.submit();
192
169
  await drain();
193
- const html = p.rows();
194
- if (!html.includes('folded into')) throw new Error('did not say it was folded');
195
- if (!html.includes('/idea/9')) throw new Error('did not link the absorbing thought');
170
+ assert.equal(bodyOf(p, 'POST', '/inbox'), undefined);
196
171
  });
197
172
 
198
- await test('a thought awaiting a nod says so — the one actionable phrase on the page', async () => {
199
- const p = bootThinkingPage({
200
- pages: [page([idea({
201
- id: 4, status: 'open',
202
- lineage_tasks: [{ task_id: 77, origin: 'route', title: 'The routed task', status: 'backlog', goal_id: 12 }],
203
- state: { state: 'protostar', position: 'well', wellGoalId: 12, awaitingNod: true, brightness: null, hasWork: false, attributable: true },
204
- })])],
205
- });
173
+ await test('a filing that fails keeps the line and says so — nothing is lost', async () => {
174
+ const p = await boot({ mine: mine([]) });
175
+ p.handlers.push((m, u) => (m === 'POST' && u.endsWith('/inbox') ? { status: 400, ok: false, data: { error: { code: 'validation_failed' } } } : null));
176
+ p.type('a thought about the sky');
177
+ p.submit();
206
178
  await drain();
207
- if (!p.rows().includes('waiting for a nod')) throw new Error('a nod queue nobody can see is a list someone must remember');
179
+ assert.equal(p.el('thought').value, 'a thought about the sky', 'the line comes back');
180
+ assert.match(p.receipt(), /not filed — validation failed/);
181
+ assert.equal(p.S().ideas.length, 0, 'nothing was invented');
208
182
  });
209
183
 
210
- await test('a thought that caused nothing says so plainly, and a declined one gives the reason', async () => {
211
- const p = bootThinkingPage({
212
- pages: [page([
213
- idea({ id: 5 }),
214
- idea({ id: 6, status: 'discarded', triage_note: 'The digest already exists.', state: { state: 'cold-body', position: 'open-space', wellGoalId: null, awaitingNod: false, brightness: null, hasWork: false, attributable: false } }),
215
- ])],
216
- });
184
+ // ═══ gravity — the real verdict ══════════════════════════════════════════════
185
+
186
+ const gravityWorld = (verdict, suggestions = [{ id: 7, title: 'The well — a goal', score: 3, matched_terms: ['well'] }]) => (m, u, body) => {
187
+ if (m === 'GET' && u.includes('/goals/suggestions')) return { status: 200, ok: true, data: { suggestions } };
188
+ if (m === 'POST' && u.endsWith('/inbox/preview-landing')) return { status: 200, ok: true, data: typeof verdict === 'function' ? verdict(body) : verdict };
189
+ return null;
190
+ };
191
+
192
+ await test('GRAVITY: the well that would catch it lights from the real verdict, and the filing names that goal', async () => {
193
+ const p = await boot({ mine: mine([]), sky: sky([well()]), versions: versions() });
194
+ p.handlers.push(gravityWorld({ outcome: 'routed', reason: null, status: 'backlog', goal: { id: 7, title: 'The well — a goal' }, version_id: 'V2', refusal: null }));
195
+ p.handlers.push((m, u) => (m === 'POST' && u.endsWith('/inbox') ? filed({ routing: { routed: true, goal_id: 7 } }) : null));
196
+ p.handlers[p.handlers.length - 1] = ((h) => (m, u) => { const r = h(m, u); if (r) r.data.routed_task = { id: 9002, status: 'backlog', goal_id: 7, title: 'a thought about the sky' }; return r; })(p.handlers[p.handlers.length - 1]);
197
+ p.type('a thought about the well');
198
+ assert.equal(p.timers.pending(), 1, 'a keystroke schedules one debounced read, not a request');
199
+ await p.timers.flush();
217
200
  await drain();
218
- const html = p.rows();
219
- if (!html.includes('nothing yet')) throw new Error('an unignited thought read as an absence');
220
- if (!html.includes('The digest already exists.')) throw new Error('lost the triager\'s reason');
221
- });
222
-
223
- await test('the descent below the roots is counted, not folded into the root count', async () => {
224
- // attachDescentToTrail deliberately EXCLUDES the roots from descent_tasks, so
225
- // a page that read one as the whole would under-report every deep thought.
226
- const p = bootThinkingPage({
227
- pages: [page([star({
228
- lineage_tasks: [
229
- { task_id: 500, origin: 'promote', title: 'One', status: 'shipped', goal_id: 7 },
230
- { task_id: 501, origin: 'develop', title: 'Two', status: 'shipped', goal_id: 7 },
231
- ],
232
- descent_tasks: [
233
- { task_id: 600, depth: 1, relation: 'split', title: 'Three', status: 'shipped' },
234
- { task_id: 601, depth: 2, relation: 'rewrite', title: 'Four', status: 'shipped' },
235
- ],
236
- })])],
237
- });
201
+ const sug = p.requests.find((r) => r.url.includes('/goals/suggestions'));
202
+ assert.ok(sug && sug.url.includes('version=V2') && sug.url.includes('title=a%20thought%20about%20the%20well'), 'the suggester is asked per building version, with the line');
203
+ same(bodyOf(p, 'POST', '/inbox/preview-landing'), { kind: 'feature', goal_id: 7 }, 'the preview is asked about the suggested goal');
204
+ assert.match(p.gravity(), /gravity · <b class="hot">The well — a goal<\/b> would catch it — a task in its well, waiting for a nod/);
205
+ assert.equal(p.S().gravityGoal, p.S().goalById.get('7'), 'the well lights in the sky');
206
+ p.submit();
238
207
  await drain();
239
- const html = p.rows();
240
- if (!html.includes('2 tasks')) throw new Error('lost a root');
241
- if (!html.includes('2 more below them')) throw new Error('lost the descent');
208
+ same(bodyOf(p, 'POST', '/inbox'), { title: 'a thought about the well', body_md: '', kind: 'feature', goal_id: 7 }, 'the goal the verdict named rides the filing');
209
+ assert.match(p.receipt(), /caught by <b>The well<\/b> · a task in its well, waiting for a nod/);
210
+ const i = findIdea(p, 900);
211
+ assert.equal(i.well, '7');
212
+ assert.equal(i.awaiting, true);
213
+ same(i.lineage.map((t) => [t.id, t.status, t.origin]), [['9002', 'backlog', 'route']]);
214
+ assert.equal(p.S().gravityGoal, null, 'and the line is clear again');
242
215
  });
243
216
 
244
- // ---------------------------------------------------------------------------
245
- // The figures are a claim about a whole trail
246
-
247
- await test('the figures count the five states, and "filed" is the server\'s total', async () => {
248
- const rows = [star(), star({ id: 12 }), remnant(), idea({ id: 13 })];
249
- const p = bootThinkingPage({ pages: [page(rows, { total: 4 })] });
217
+ await test('gravity READS the verdict, it does not compute one — the suggester\'s pick means nothing until the matrix says routed', async () => {
218
+ const p = await boot({ mine: mine([]), sky: sky([well()]), versions: versions() });
219
+ p.handlers.push(gravityWorld({ outcome: 'floats', reason: 'judgment_kind', status: null, goal: { id: 7, title: 'The well — a goal' }, version_id: null, refusal: null }));
220
+ p.type('the well the well the well');
221
+ await p.timers.flush();
250
222
  await drain();
251
- const figs = p.figs();
252
- // 4 filed, 3 ignited (two stars + the remnant), 2 burning, 1 burned out.
253
- for (const [n, label] of [['4', 'filed'], ['3', 'ignited'], ['2', 'still burning'], ['1', 'burned out']]) {
254
- if (!new RegExp(`>${n}</span>\\s*<span class="tr-fig__label">${label}<`).test(figs)) {
255
- throw new Error(`figure "${label}" is not ${n}`);
256
- }
257
- }
258
- });
259
-
260
- await test('the page keeps reading until the trail is exhausted, so the figures cover all of it', async () => {
261
- // The defect this is under: figures computed over ONE window look exactly
262
- // like figures computed over a life.
263
- const p = bootThinkingPage({
264
- pages: [
265
- page([star(), star({ id: 12 })], { total: 3, offset: 0 }),
266
- page([remnant()], { total: 3, offset: 2 }),
267
- ],
268
- });
269
- await drain(20);
270
- if (p.requests.filter((u) => /by-builder/.test(u)).length !== 2) throw new Error('stopped after one window');
271
- if (!/>3<\/span>\s*<span class="tr-fig__label">filed</.test(p.figs())) throw new Error('lost the total');
272
- if (!/>1<\/span>\s*<span class="tr-fig__label">burned out</.test(p.figs())) throw new Error('never counted the second window');
273
- if (p.foot().hidden) throw new Error('the foot vanished once loading finished');
274
- });
275
-
276
- await test('a trail longer than the cap says so rather than quietly describing its newest slice', async () => {
277
- const big = (offset) => page(
278
- Array.from({ length: 100 }, (_, i) => star({ id: offset * 100 + i })),
279
- { total: 900, offset: offset * 100 }
280
- );
281
- const p = bootThinkingPage({ pages: [big(0), big(1), big(2), big(3)] });
282
- await drain(40);
283
- const foot = p.foot().innerHTML;
284
- if (!/newest 300/.test(foot)) throw new Error(`the cap is silent: ${foot}`);
285
- if (p.requests.filter((u) => /by-builder/.test(u)).length !== 3) throw new Error('read past the cap');
286
- });
287
-
288
- // ---------------------------------------------------------------------------
289
- // The filter strip
290
-
291
- await test('the filter strip is built from the five states, in the shared order', async () => {
292
- const p = bootThinkingPage({ pages: [page([star()])] });
223
+ assert.equal(p.S().gravityGoal, null, 'every word matches the goal and the page still lights nothing');
224
+ assert.match(p.gravity(), /nothing catches it — it will float in open space until something does/);
225
+ p.handlers.push((m, u) => (m === 'POST' && u.endsWith('/inbox') ? filed() : null));
226
+ p.submit();
293
227
  await drain();
294
- const ids = p.tabs().ids;
295
- const expected = ['everything', ...p.objects().ORDER];
296
- if (ids.join(',') !== expected.join(',')) throw new Error(`strip is ${ids.join(',')}`);
228
+ assert.equal(bodyOf(p, 'POST', '/inbox').goal_id, undefined, 'and no goal is sent that the verdict did not name');
297
229
  });
298
230
 
299
- await test('choosing a filter shows only that object', async () => {
300
- const p = bootThinkingPage({ pages: [page([star(), remnant()])] });
231
+ await test('a refusal is the server\'s own sentence, and a stale answer never paints over a newer one', async () => {
232
+ const p = await boot({ mine: mine([]), versions: versions() });
233
+ p.handlers.push(gravityWorld({ outcome: 'refused', reason: 'protected_scope', status: null, goal: { id: 7, title: 'The well' }, version_id: null, refusal: { reason: 'The well is protected scope; a Metic must file into it.' } }));
234
+ p.type('first draft');
235
+ p.type('second draft');
236
+ assert.equal(p.timers.pending(), 1, 'the debounce is one timer, the newer keystroke replacing the older');
237
+ await p.timers.flush();
301
238
  await drain();
302
- p.filter('remnant');
303
- const html = p.rows();
304
- if (!html.includes('It burned, then it went')) throw new Error('the remnant is missing');
305
- if (html.includes('It ignited')) throw new Error('the filter let a star through');
239
+ assert.equal(p.requests.filter((r) => r.url.endsWith('/inbox/preview-landing')).length, 1, 'one preview for the draft as it stands');
240
+ assert.equal(p.gravity(), 'The well is protected scope; a Metic must file into it.');
241
+ p.type('');
242
+ assert.match(p.gravity(), /^gravity — the goal that catches it lights as you type; letting it float is a real answer$/, 'an empty line rests the sentence');
306
243
  });
307
244
 
308
- await test('a filter nobody has any of is hidden — but never "everything", and never the active one', async () => {
309
- const p = bootThinkingPage({ pages: [page([star()])] });
245
+ // ═══ the composer — full ═════════════════════════════════════════════════════
246
+
247
+ await test('FULL: the five questions are the template\'s, one at a time, with skip and cancel, then one POST /inbox with the fields', async () => {
248
+ const p = await boot({ mine: mine([]), versions: versions() });
249
+ p.handlers.push((m, u) => (m === 'POST' && u.endsWith('/inbox') ? filed({ grade: 'full', completeness_score: 40 }) : null));
250
+ assert.equal(p.el('fullToggle').hidden, false);
251
+ p.click('fullToggle');
252
+ assert.equal(p.el('fullToggle').getAttribute('aria-pressed'), 'true');
253
+ assert.equal(p.el('thought').placeholder, 'release a thought — one line, then five questions');
254
+ p.type('a full thought');
255
+ p.submit();
256
+ assert.match(p.steps(), /<b>full idea · 1 of 5<\/b><span>What made you have the idea<\/span><span class="hint">~25 characters<\/span>/);
257
+ assert.equal(p.el('thought').placeholder, 'What made you have the idea');
258
+ assert.equal(p.el('fileGo').textContent, 'next');
259
+ p.type('because the room was a list and a list is not a sky');
260
+ p.submit();
261
+ assert.match(p.steps(), /2 of 5<\/b><span>Scope it as best as you can here/);
262
+ p.click('stepSkip');
263
+ assert.match(p.steps(), /3 of 5<\/b><span>What should an engineer or artist know prior to building/);
264
+ // cancel: the line comes back as a quick idea, nothing filed
265
+ p.click('stepCancel');
266
+ assert.equal(p.el('fullToggle').getAttribute('aria-pressed'), 'false');
267
+ assert.equal(p.el('thought').value, 'a full thought');
268
+ assert.equal(p.el('fullSteps').hidden, true);
269
+ assert.equal(bodyOf(p, 'POST', '/inbox'), undefined, 'cancel files nothing');
270
+ // and again, all the way through
271
+ p.click('fullToggle');
272
+ p.type('a full thought');
273
+ p.submit();
274
+ const answers = ['because the room was a list and a list is not a sky', '', 'read the template first', 'it is the ideator\'s home', 'the owner reads it daily'];
275
+ for (let n = 0; n < 5; n++) {
276
+ assert.match(p.steps(), new RegExp(`${n + 1} of 5`));
277
+ if (n === 4) assert.equal(p.el('fileGo').textContent, 'file the full idea');
278
+ p.type(answers[n]); p.submit();
279
+ }
310
280
  await drain();
311
- const t = p.tabs();
312
- if (t.hidden.remnant !== true) throw new Error('an empty filter is offered');
313
- if (t.hidden.star === true) throw new Error('hid a filter with rows behind it');
314
- if (t.hidden.everything === true) throw new Error('hid the way back to everything');
315
- // Select an empty one, then re-render: the control a reader is looking
316
- // through must not disappear out from under them.
317
- p.filter('remnant');
318
- if (t.hidden.remnant === true) throw new Error('hid the filter the reader is using');
281
+ same(bodyOf(p, 'POST', '/inbox'), {
282
+ title: 'a full thought', grade: 'full', template_key: 'base-v2',
283
+ field_values: { why_had: answers[0], scope: '', build_context: answers[2], fit: answers[3], success: answers[4] },
284
+ }, 'the hall form\'s full body — grade, template_key and every field by its key');
285
+ assert.match(p.receipt(), /filed as a full idea · <b>1 of 5<\/b> answers above the floor · nothing caught it · floating in open space/);
286
+ assert.equal(findIdea(p, 900).grade, 'full');
287
+ assert.equal(p.el('fullSteps').hidden, true, 'the steps close');
319
288
  });
320
289
 
321
- await test('an empty filter says which one is empty, and an empty trail invites a first thought', async () => {
322
- const p = bootThinkingPage({ pages: [page([star()])] });
323
- await drain();
324
- p.filter('remnant');
325
- if (!/Nothing of yours is burned out/.test(p.rows())) throw new Error('an empty filter said nothing useful');
326
-
327
- const q = bootThinkingPage({ pages: [page([])] });
328
- await drain();
329
- if (!/File a thought/.test(q.rows())) throw new Error('an empty trail read as a failure');
290
+ await test('Escape inside the questions cancels them; the questions never live in the page', async () => {
291
+ const p = await boot({ mine: mine([]) });
292
+ p.click('fullToggle');
293
+ p.type('a full thought');
294
+ p.submit();
295
+ assert.equal(p.el('fullSteps').hidden, false);
296
+ p.el('thought').fire('keydown', { key: 'Escape', preventDefault() {} });
297
+ assert.equal(p.el('fullSteps').hidden, true);
298
+ assert.equal(p.el('thought').value, 'a full thought');
299
+ const fs = await import('node:fs');
300
+ const src = fs.readFileSync('modules/hall-ui/public/sky-composer.js', 'utf8');
301
+ for (const key of ['why_had', 'build_context']) assert.ok(!new RegExp(`['"]${key}['"]`).test(src), `the composer carries no copy of the template field ${key}`);
330
302
  });
331
303
 
332
- // ---------------------------------------------------------------------------
333
- // It is the AUTHOR'S own view
334
-
335
- await test('the trail read is for the viewer\'s own id, taken from /me', async () => {
336
- const p = bootThinkingPage({ pages: [page([star()])], viewer: { id: 4242 } });
337
- await drain();
338
- if (!p.requests.some((u) => u.includes('/inbox/by-builder/4242'))) {
339
- throw new Error(`never read the viewer's own trail: ${p.requests.join(' ')}`);
340
- }
304
+ await test('without a template there is no full idea to file, so the switch is not offered', async () => {
305
+ const p = await boot({ mine: mine([]), template: null });
306
+ assert.equal(p.el('fullToggle').hidden, true);
307
+ same(p.SKY().fields(), []);
341
308
  });
342
309
 
343
- await test('the table carries five columns and no id column', async () => {
344
- // The agreed shape, and the reason: on the one page where every row is yours,
345
- // an id is a lookup key with nothing to look up.
346
- const p = bootThinkingPage({ pages: [page([star()])] });
347
- await drain();
348
- const html = p.rows();
349
- const heads = html.match(/<th[ >]/g) || [];
350
- if (heads.length !== 5) throw new Error(`expected 5 columns, got ${heads.length}`);
351
- if (/ledger__num/.test(html)) throw new Error('the id column came back');
352
- });
310
+ // ═══ the nod ═════════════════════════════════════════════════════════════════
353
311
 
354
- await test('a title is escaped before it reaches the page', async () => {
355
- const p = bootThinkingPage({ pages: [page([star({ title: '<script>x</script>' })])] });
312
+ await test('THE NOD is drawn only for a holder of idea.triage, and runs the existing promote path', async () => {
313
+ const held = await boot({ mine: mine([waiting()]), sky: sky([well()]), viewer: { id: 95, permissions: { 'idea.triage': true } } });
314
+ assert.equal(held.S().canNod, true);
315
+ held.SKY().pick({ kind: 'idea', ref: findIdea(held, 14) }, false);
316
+ assert.match(held.panel(), /id="nodBtn"[^>]*>nod — make it claimable</);
317
+ held.handlers.push((m, u) => (m === 'POST' && u.endsWith('/tasks/9001/promote') ? { status: 200, ok: true, data: { ok: true } } : null));
318
+ held.click('nodBtn');
356
319
  await drain();
357
- if (p.rows().includes('<script>')) throw new Error('rendered raw markup from a row');
320
+ assert.ok(held.requests.some((r) => r.method === 'POST' && r.url === '/api/bongos/tasks/9001/promote'), 'the nod is the routed task\'s promote');
321
+ assert.equal(held.el('nodBtn').textContent, 'nodded — a builder can claim it now');
322
+ assert.equal(findIdea(held, 14).awaiting, false);
323
+ assert.equal(findIdea(held, 14).state.awaitingNod, false);
324
+
325
+ const not = await boot({ mine: mine([waiting()]), sky: sky([well()]), viewer: { id: 95, permissions: { 'board.vote.cast': true } } });
326
+ assert.equal(not.S().canNod, false);
327
+ not.SKY().pick({ kind: 'idea', ref: findIdea(not, 14) }, false);
328
+ assert.doesNotMatch(not.panel(), /nodBtn/, 'no button for a builder who may not triage');
329
+ assert.match(not.panel(), /waiting for a nod/, 'the fact is still spoken');
358
330
  });
359
331
 
360
- await test('a failed trail read leaves an honest line, not a blank page', async () => {
361
- const p = bootThinkingPage({ pages: [], trailStatus: 500 });
332
+ await test('a nod the server refuses says why and stays pressable', async () => {
333
+ const p = await boot({ mine: mine([waiting()]), sky: sky([well()]), viewer: { id: 95, permissions: { 'idea.triage': true } } });
334
+ p.SKY().pick({ kind: 'idea', ref: findIdea(p, 14) }, false);
335
+ p.handlers.push((m, u) => (m === 'POST' && u.endsWith('/promote') ? { status: 409, ok: false, data: { error: { code: 'deps_not_shipped' } } } : null));
336
+ p.click('nodBtn');
362
337
  await drain();
363
- if (!/Could not read your idea trail/.test(p.rows())) throw new Error('failed silently');
338
+ assert.equal(p.el('nodBtn').textContent, 'could not nod — deps not shipped');
339
+ assert.equal(p.el('nodBtn').disabled, false);
340
+ assert.equal(findIdea(p, 14).state.awaitingNod, true);
364
341
  });
365
342
 
343
+ console.log('');
366
344
  summary();