@bongos/core 1.19.927 → 1.19.929

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 (53) hide show
  1. package/.bongos-core.json +83 -53
  2. package/docs/copy-inventory.md +36 -15
  3. package/docs/copy-registry.json +239 -28
  4. package/docs/file-map.md +1 -1
  5. package/docs/module-api-changelog.md +4 -0
  6. package/modules/hall-ui/public/atlas.html +1 -1
  7. package/modules/hall-ui/public/blockers.html +1 -1
  8. package/modules/hall-ui/public/board-room.html +1 -1
  9. package/modules/hall-ui/public/collab.html +1 -1
  10. package/modules/hall-ui/public/copy-desk.html +1 -1
  11. package/modules/hall-ui/public/deploy.html +1 -1
  12. package/modules/hall-ui/public/diagrams.html +1 -1
  13. package/modules/hall-ui/public/drachmae.html +1 -1
  14. package/modules/hall-ui/public/gate.html +1 -1
  15. package/modules/hall-ui/public/goals.html +1 -1
  16. package/modules/hall-ui/public/government.html +1 -1
  17. package/modules/hall-ui/public/idea-objects.js +109 -0
  18. package/modules/hall-ui/public/idea.html +1 -1
  19. package/modules/hall-ui/public/ideas.html +1 -1
  20. package/modules/hall-ui/public/index.html +1 -1
  21. package/modules/hall-ui/public/modules.html +1 -1
  22. package/modules/hall-ui/public/people.html +1 -1
  23. package/modules/hall-ui/public/primer.html +1 -1
  24. package/modules/hall-ui/public/profile.html +1 -1
  25. package/modules/hall-ui/public/project-settings.html +1 -1
  26. package/modules/hall-ui/public/ranks.html +1 -1
  27. package/modules/hall-ui/public/roadmap.html +1 -1
  28. package/modules/hall-ui/public/roster.html +1 -1
  29. package/modules/hall-ui/public/sessions.html +1 -1
  30. package/modules/hall-ui/public/settings.html +1 -1
  31. package/modules/hall-ui/public/shell.js +11 -0
  32. package/modules/hall-ui/public/studio.html +1 -1
  33. package/modules/hall-ui/public/task.html +1 -1
  34. package/modules/hall-ui/public/thinking.css +286 -0
  35. package/modules/hall-ui/public/thinking.html +74 -0
  36. package/modules/hall-ui/public/thinking.js +418 -0
  37. package/modules/hall-ui/public/thinking.states.json +20 -0
  38. package/modules/hall-ui/public/watch.html +1 -1
  39. package/modules/hall-ui/public/work.html +1 -1
  40. package/package-lock.json +2 -2
  41. package/package.json +1 -1
  42. package/scripts/gds/provision-config.js +11 -0
  43. package/scripts/gds/provision-core-upgrade.js +90 -10
  44. package/scripts/gds/provision.js +3 -3
  45. package/src/bongos/serve-internal.js +7 -1
  46. package/src/module-api.js +1 -1
  47. package/tests/core_upgrade_runner.mjs +153 -0
  48. package/tests/hall_nav.mjs +1 -1
  49. package/tests/hall_page_gate_map.mjs +6 -0
  50. package/tests/hall_thinking_room.mjs +366 -0
  51. package/tests/helpers.mjs +124 -0
  52. package/tests/nav_permission_atoms.mjs +1 -1
  53. package/tests/provision.mjs +41 -0
@@ -0,0 +1,418 @@
1
+ // modules/hall-ui/public/thinking.js — THE ROOM (task 1004098 / BV2.R03 of
2
+ // goal 1000095, criterion `ideator-nothing-is-lost`; spec
3
+ // docs/specs/bongos-v2-ideator-surface.md, the agreed shape is
4
+ // docs/design/mocks/ideator-surface/Room.dc.html).
5
+ //
6
+ // "A plain overview of one's own thinking: what was filed, what it became, its
7
+ // size, and whether it still burns." It is deliberately unglamorous — rows, a
8
+ // size, a state — because it is the page a person opens daily and the sky is
9
+ // the ambient one.
10
+ //
11
+ // IT IS THE AUTHOR'S OWN VIEW, and that is spec decision 19 rather than a
12
+ // convenience: the profile's idea trail (profile.js, `activity-ideas`) stays
13
+ // exactly as it is, because it is what SOMEONE ELSE sees on you — three columns,
14
+ // capped, inside an activity log about a person. This page is about the
15
+ // thinking, not about the thinker, so it reads your own trail whole and spends
16
+ // its width on what the profile has no room for: the size and the state. The
17
+ // two surfaces share a READ and nothing else, which is why neither is built out
18
+ // of the other.
19
+ //
20
+ // EVERY NUMBER ON THIS PAGE IS THE SERVER'S. `GET /inbox/by-builder/:id`
21
+ // composes four attachers over each trail row — the descent, what it paid
22
+ // (`size`), which of the five objects it is (`state`) — and this file draws
23
+ // them. It derives no state, compresses no size and counts no brightness:
24
+ // modules/ideas/idea-state.js exists precisely so that the room, the descent
25
+ // and the sky cannot answer the same question three ways (ADR 0322), and the
26
+ // word for each object comes from the one shared table in ./idea-objects.js.
27
+ //
28
+ // WHY IT LOADS THE WHOLE TRAIL AND NOT ONE PAGE OF IT. The four figures over
29
+ // the list — filed / ignited / still burning / burned out — are a claim about a
30
+ // person's whole body of thinking, and computed over a 20-row window they would
31
+ // be a claim about a window while looking exactly like a claim about a life.
32
+ // So the page renders its first window immediately (the read is not cheap: a
33
+ // descent walk and a ledger read per page) and then keeps fetching in the
34
+ // background, re-rendering as it goes, until the trail is exhausted or the cap
35
+ // below is reached. If the cap is reached the foot SAYS the figures cover the
36
+ // newest N — an honest partial beats a silent one.
37
+
38
+ (() => {
39
+ 'use strict';
40
+
41
+ const API = '/api/bongos';
42
+ // baseUrl:'' — this page builds full paths itself through api.request, the
43
+ // way work.js and copy-desk.js do, because the trail read is reached by a
44
+ // path the generated namespaced client does not carry a method for. The two
45
+ // idioms must never be mixed on one page (see idea.js's note on what happens
46
+ // when they are).
47
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
48
+ const { escapeHtml, $, fmtDate } = window.OTB;
49
+ const objects = window.OTBIdeaObjects;
50
+
51
+ const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
52
+ const ideaHref = (id) => `${hallBase()}/idea/${encodeURIComponent(id)}`;
53
+ const taskHref = (id) => `${hallBase()}/task/${encodeURIComponent(id)}`;
54
+
55
+ // The server's own ceiling (modules/ideas/trail.js IDEA_TRAIL_MAX_LIMIT), so
56
+ // the background load takes the fewest round trips the route allows.
57
+ const PAGE = 100;
58
+ // Three pages. A trail longer than this exists — and when it does the foot
59
+ // says so rather than the figures quietly describing the newest 300 as though
60
+ // they described everything.
61
+ const CAP = 300;
62
+
63
+ const state = {
64
+ ideas: [],
65
+ total: 0,
66
+ capped: false,
67
+ loading: false,
68
+ filter: 'everything',
69
+ tabs: null,
70
+ };
71
+
72
+ // ---- what a row says it became -------------------------------------------
73
+ //
74
+ // The OUTCOME column, and the one place this file composes a sentence rather
75
+ // than printing a field. It is built from the ledger facts the row already
76
+ // carries and never from the state: a satellite says it was folded in because
77
+ // `merged_into_idea_id` is set, not because the resolver called it a
78
+ // satellite — so a disagreement between the two shows up as a visibly odd row
79
+ // instead of being smoothed over into agreement.
80
+ //
81
+ // `descent_tasks` is what the roots went on to cause, and it deliberately
82
+ // EXCLUDES those roots (attachDescentToTrail leaves them out so the first hop
83
+ // is not paid twice), which is why the two counts are added rather than one
84
+ // being read as the whole.
85
+ function becameHtml(idea) {
86
+ if (idea.merged_into_idea_id) {
87
+ const title = idea.merged_into_idea_title || `idea ${idea.merged_into_idea_id}`;
88
+ return `folded into <a href="${ideaHref(idea.merged_into_idea_id)}">${escapeHtml(title)}</a>`;
89
+ }
90
+
91
+ const lineage = Array.isArray(idea.lineage_tasks) ? idea.lineage_tasks : [];
92
+ const roots = new Set();
93
+ for (const t of lineage) if (t && t.task_id != null) roots.add(String(t.task_id));
94
+ // The legacy column is the only answer for a promotion that predates
95
+ // ideas_task_lineage (the same guard idea.js's descentRows() carries).
96
+ if (roots.size === 0 && idea.promoted_to_task_id != null) roots.add(String(idea.promoted_to_task_id));
97
+
98
+ if (roots.size === 0) {
99
+ if (idea.status === 'discarded') {
100
+ return idea.triage_note
101
+ ? `declined — ${escapeHtml(idea.triage_note)}`
102
+ : 'declined at triage';
103
+ }
104
+ return '<span class="tr-quiet">nothing yet</span>';
105
+ }
106
+
107
+ const bits = [];
108
+ // A single root gets its name and a link, because with one task the title
109
+ // IS the outcome. Several get a count — five titles in a cell is not an
110
+ // overview, which is what this page is.
111
+ if (roots.size === 1) {
112
+ const id = [...roots][0];
113
+ const named = lineage.find((t) => t && String(t.task_id) === id);
114
+ const title = (named && named.title) || idea.promoted_task_title || `task ${id}`;
115
+ bits.push(`<a href="${taskHref(id)}">${escapeHtml(title)}</a>`);
116
+ } else {
117
+ bits.push(`${roots.size} tasks`);
118
+ }
119
+
120
+ const descent = Array.isArray(idea.descent_tasks) ? idea.descent_tasks : [];
121
+ if (descent.length > 0) {
122
+ bits.push(`<span class="tr-quiet">and ${descent.length} more below ${descent.length === 1 ? 'it' : 'them'}</span>`);
123
+ }
124
+
125
+ // The nod is the one ACTIONABLE thing this column can carry: a task sitting
126
+ // at backlog inside a goal is one Metic+ promote from claimable, and the
127
+ // whole point of surfacing position (idea-state.js) was that the queue
128
+ // becomes something a person can see rather than remember to open.
129
+ const pos = objects.positionWord(idea.state);
130
+ if (pos === 'waiting for a nod') bits.push('<span class="tr-nod">waiting for a nod</span>');
131
+
132
+ return bits.join(' · ');
133
+ }
134
+
135
+ // ---- size ----------------------------------------------------------------
136
+ //
137
+ // The bar's width is the server's `scale` and nothing else. Size is
138
+ // long-tailed, so economy compresses it ONCE at the read
139
+ // (modules/economy/idea-size.js compressIdeaSize) and the direction is
140
+ // explicit that the compressed value is what a drawing uses while the true
141
+ // figure is shown beside it. A second curve here — a log of a log — is
142
+ // exactly how the sky and the room would end up drawing the same thought at
143
+ // two different sizes.
144
+ function sizeHtml(idea) {
145
+ const size = idea.size;
146
+ if (!size || typeof size.credits !== 'number') {
147
+ // `size: null` is "the reward port could not be asked", not "it earned
148
+ // nothing" (trail-size.js). Drawing it as a zero would be a claim.
149
+ return '<span class="tr-quiet" title="the ledger could not be read">unknown</span>';
150
+ }
151
+ if (size.credits <= 0) return '<span class="tr-quiet">—</span>';
152
+ const pct = Math.max(4, Math.round(Number(size.scale || 0) * 100));
153
+ // A size computed from the descent an idea already had, rather than from
154
+ // payouts actually made (spec decision 17). The marker rides wherever the
155
+ // number is shown or the number quietly claims to be money that was spent.
156
+ const mark = size.historical === true
157
+ ? '<abbr class="tr-hist" title="computed from the descent this idea already had — not payouts that were made">part historical</abbr>'
158
+ : '';
159
+ // THE BAR KEEPS ITS WIDTH AND LOSES ITS COLOUR WHEN THE WORK IS GONE, which
160
+ // is the agreed shape (Room.dc.html draws the bar in the accent only while
161
+ // `alive > 0`) and the picture the whole two-channel model exists for: a
162
+ // thought that caused a great deal of work the project then tore out reads
163
+ // LARGE AND DEAD, not small. Width is size and never falls; colour is the
164
+ // live channel. Set from the same `state` the object column draws, so the
165
+ // two cells of a row cannot disagree — and only when the removal facts were
166
+ // actually READABLE, because `brightness: null` is "we could not look" and
167
+ // greying a bar on that basis would state something nobody checked.
168
+ const st = idea.state;
169
+ const out = st && st.attributable === true && st.hasWork === true && st.brightness === 0;
170
+ return `<span class="tr-size"${out ? ' data-lit="out"' : ''}>
171
+ <span class="tr-size__bar" style="--w:${pct}%"></span>
172
+ <span class="tr-size__n">${escapeHtml(String(size.credits))}</span>
173
+ </span>${mark}`;
174
+ }
175
+
176
+ // ---- the object ----------------------------------------------------------
177
+ //
178
+ // The mark restates in SHAPE what the cell also says in words — the sheet's
179
+ // own rule for the grade badge, and the reason the glyph is not decoration: a
180
+ // mark distinguished only by colour tells a colour-blind reader nothing.
181
+ // Brightness rides the glyph's opacity AND the sentence beside it, so dimness
182
+ // is never the only channel either.
183
+ function objectHtml(idea) {
184
+ const st = idea.state;
185
+ const o = objects.objectFor(st && st.state);
186
+ const bright = objects.brightnessWord(st);
187
+ const pos = objects.positionWord(st);
188
+ // `brightness: null` on a thought that HAS work means the removal facts
189
+ // could not be read. It must not be drawn as a dim star — the glyph stays
190
+ // full and the words carry the doubt.
191
+ const lit = (st && st.hasWork === true && st.attributable === true && typeof st.brightness === 'number')
192
+ ? st.brightness
193
+ : 1;
194
+ const sub = bright || pos || o.blurb.split('.')[0];
195
+ return `<span class="tr-obj" data-object="${escapeHtml(String((st && st.state) || 'unknown'))}" style="--lit:${lit.toFixed(2)}">
196
+ <span class="tr-obj__glyph" aria-hidden="true"></span>
197
+ <span class="tr-obj__body">
198
+ <span class="tr-obj__label">${escapeHtml(o.label)}</span>
199
+ <span class="tr-obj__sub">${escapeHtml(sub)}</span>
200
+ </span>
201
+ </span>`;
202
+ }
203
+
204
+ function rowHtml(idea) {
205
+ const st = idea.state;
206
+ // NO ID COLUMN, which is the agreed shape (the mock's five columns are the
207
+ // thought, filed, became, size, object) and not only a saving of width: an
208
+ // id is a lookup key, and on the one page where every row is YOURS there is
209
+ // nothing to look up — you know which thought it is by reading it. The id
210
+ // is still one click away, on the record the title links to, and it rides
211
+ // the link's title so it can be copied without leaving.
212
+ return `<tr data-object="${escapeHtml(String((st && st.state) || 'unknown'))}">
213
+ <td class="tr-thought"><a href="${ideaHref(idea.id)}" title="Idea ${escapeHtml(String(idea.id))}">${escapeHtml(idea.title || `Idea ${idea.id}`)}</a></td>
214
+ <td class="ledger__date">${escapeHtml(fmtDate(idea.captured_at))}</td>
215
+ <td class="tr-became">${becameHtml(idea)}</td>
216
+ <td class="tr-sizecell">${sizeHtml(idea)}</td>
217
+ <td class="tr-objcell">${objectHtml(idea)}</td>
218
+ </tr>`;
219
+ }
220
+
221
+ // ---- the figures ---------------------------------------------------------
222
+ //
223
+ // Four counts, each one a plain tally over the rows in hand — no thresholds
224
+ // and no judgement. "filed" is the SERVER's total rather than the loaded
225
+ // length, because that one is knowable in full from the first response even
226
+ // when the rest is still arriving.
227
+ //
228
+ // "ignited" is `hasWork`, which is the resolver's own word for "this has been
229
+ // paid for something" — not a status. An idea can be promoted, sit in a goal,
230
+ // and have ignited nothing; that is the distinction the direction draws and
231
+ // counting statuses here would erase it.
232
+ function figuresHtml() {
233
+ const rows = state.ideas;
234
+ const by = (fn) => rows.filter(fn).length;
235
+ const figs = [
236
+ { n: state.total, label: 'filed', tone: '' },
237
+ { n: by((i) => i.state && i.state.hasWork === true), label: 'ignited', tone: '' },
238
+ { n: by((i) => i.state && i.state.state === 'star'), label: 'still burning', tone: 'warm' },
239
+ { n: by((i) => i.state && i.state.state === 'remnant'), label: 'burned out', tone: 'dim' },
240
+ ];
241
+ return figs.map((f) => `<div class="tr-fig"${f.tone ? ` data-tone="${f.tone}"` : ''}>
242
+ <span class="tr-fig__n">${escapeHtml(String(f.n))}</span>
243
+ <span class="tr-fig__label">${escapeHtml(f.label)}</span>
244
+ </div>`).join('');
245
+ }
246
+
247
+ // ---- the filter strip ----------------------------------------------------
248
+ //
249
+ // The tab ids ARE the five state values, so a deep link is `#remnant` and the
250
+ // strip cannot drift from the vocabulary: the order comes from
251
+ // idea-objects.js ORDER and the labels from its table.
252
+ //
253
+ // A tab whose count is zero is HIDDEN rather than shown empty or disabled.
254
+ // Five dead pills over a new builder's two ideas is noise about objects they
255
+ // do not have; the strip grows as their thinking does. It is done by
256
+ // visibility and not by rebuilding the strip, so the hash of a filter that
257
+ // later empties still resolves instead of throwing.
258
+ function buildTabs() {
259
+ const kit = window.OTBKit;
260
+ const mount = $('#tr-filters');
261
+ if (!kit || !mount || state.tabs) return;
262
+ const tabs = [{ id: 'everything', label: 'everything' }].concat(
263
+ objects.ORDER.map((id) => ({ id, label: objects.objectFor(id).label }))
264
+ );
265
+ state.tabs = kit.makeTabs({
266
+ mount,
267
+ tabs,
268
+ hash: true,
269
+ // syncTabs BEFORE the rows, and not only on a load: the "never hide the
270
+ // filter the reader is looking through" rule is a property of the
271
+ // CURRENT filter, so it has to be re-applied whenever that changes. A
272
+ // filter can be selected without a load — the strip syncs its hash, so
273
+ // `#remnant` on a trail with no remnants arrives here directly — and
274
+ // painting rows alone would leave the reader on an empty list whose
275
+ // control had just been hidden.
276
+ onChange: (id) => { state.filter = id; syncTabs(); paintRows(); },
277
+ });
278
+ }
279
+
280
+ function syncTabs() {
281
+ if (!state.tabs) return;
282
+ const counts = { everything: state.ideas.length };
283
+ for (const id of objects.ORDER) counts[id] = 0;
284
+ for (const i of state.ideas) {
285
+ const k = i.state && i.state.state;
286
+ if (k in counts) counts[k] += 1;
287
+ }
288
+ for (const id of Object.keys(counts)) {
289
+ state.tabs.setCount(id, counts[id]);
290
+ const btn = state.tabs.el.querySelector(`[data-tab="${window.CSS.escape(id)}"]`);
291
+ // The active filter is never hidden, even at zero: hiding the control a
292
+ // reader is currently looking through would leave them staring at an
293
+ // empty list with no way back to everything.
294
+ if (btn) btn.hidden = counts[id] === 0 && id !== 'everything' && id !== state.filter;
295
+ }
296
+ }
297
+
298
+ // ---- painting ------------------------------------------------------------
299
+
300
+ function visibleIdeas() {
301
+ if (state.filter === 'everything') return state.ideas;
302
+ return state.ideas.filter((i) => i.state && i.state.state === state.filter);
303
+ }
304
+
305
+ function paintRows() {
306
+ const rows = visibleIdeas();
307
+ const body = $('#tr-body');
308
+ if (rows.length === 0) {
309
+ body.innerHTML = state.ideas.length === 0
310
+ ? `<p class="tr-empty">You have not filed anything yet. <a href="${hallBase()}/ideas">File a thought</a> — it takes one line.</p>`
311
+ : `<p class="tr-empty">Nothing of yours is ${escapeHtml(objects.objectFor(state.filter).label)} right now.</p>`;
312
+ return;
313
+ }
314
+ body.innerHTML = `<table class="ledger tr-table">
315
+ <thead><tr>
316
+ <th>The thought</th>
317
+ <th class="ledger__date">Filed</th>
318
+ <th>Became</th>
319
+ <th>Size</th>
320
+ <th>Object</th>
321
+ </tr></thead>
322
+ <tbody>${rows.map(rowHtml).join('')}</tbody>
323
+ </table>`;
324
+ }
325
+
326
+ function paint() {
327
+ $('#tr-figs').innerHTML = figuresHtml();
328
+ syncTabs();
329
+ paintRows();
330
+ const foot = $('#tr-foot');
331
+ const bits = [];
332
+ if (state.loading) bits.push(`Reading the rest of your trail — ${state.ideas.length} of ${state.total} so far.`);
333
+ else if (state.capped) bits.push(`Showing your newest ${state.ideas.length} of ${state.total}; the figures above count those.`);
334
+ if (!state.loading && !state.capped && state.ideas.length > 0) {
335
+ bits.push('Size never falls. Brightness is how much of the work a thought caused is still alive, and that is the part that can go out.');
336
+ }
337
+ foot.innerHTML = bits.map((b) => escapeHtml(b)).join(' ');
338
+ foot.hidden = bits.length === 0;
339
+ }
340
+
341
+ function subLine() {
342
+ const sub = $('#tr-sub');
343
+ if (!sub) return;
344
+ sub.textContent = state.total === 0
345
+ ? 'Nothing filed yet'
346
+ : `${state.total} thought${state.total === 1 ? '' : 's'} filed`;
347
+ }
348
+
349
+ // ---- the read ------------------------------------------------------------
350
+
351
+ async function fetchPage(builderId, offset) {
352
+ const r = await api.request('GET',
353
+ `${API}/inbox/by-builder/${encodeURIComponent(builderId)}?limit=${PAGE}&offset=${offset}`);
354
+ if (r.status === 401) { window.location.assign(`${API}/auth/web/start`); return null; }
355
+ if (!r.ok || !r.data) throw new Error(`idea trail → ${r.status}`);
356
+ return r.data;
357
+ }
358
+
359
+ function fail(msg) {
360
+ $('#tr-body').innerHTML = `<p class="tr-empty">${escapeHtml(msg)}</p>`;
361
+ $('#tr-foot').hidden = true;
362
+ }
363
+
364
+ async function main() {
365
+ // Who is looking. The page is the AUTHOR's own view, so the id is the
366
+ // viewer's and never a query parameter: /thinking?builder=41 would be a
367
+ // second, worse profile page, and the profile already exists.
368
+ const me = await api.request('GET', `${API}/me`);
369
+ if (me.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
370
+ if (!me.ok || !me.data || !me.data.builder) return fail('Could not work out who you are — try signing in again.');
371
+ const builderId = me.data.builder.id;
372
+
373
+ let first;
374
+ try {
375
+ first = await fetchPage(builderId, 0);
376
+ } catch (_) {
377
+ return fail('Could not read your idea trail.');
378
+ }
379
+ if (!first) return;
380
+
381
+ state.ideas = Array.isArray(first.ideas) ? first.ideas : [];
382
+ state.total = Number(first.total) || state.ideas.length;
383
+ state.loading = state.ideas.length < state.total && state.ideas.length < CAP;
384
+ subLine();
385
+ buildTabs();
386
+ paint();
387
+
388
+ // The rest, in the background. A failure here leaves the first window
389
+ // rendered and says so in the foot rather than blanking a page that is
390
+ // already useful — the same posture idea.js takes with its /me read.
391
+ while (state.loading) {
392
+ let next;
393
+ try {
394
+ next = await fetchPage(builderId, state.ideas.length);
395
+ } catch (_) {
396
+ state.loading = false;
397
+ state.capped = true;
398
+ break;
399
+ }
400
+ if (!next) return;
401
+ const batch = Array.isArray(next.ideas) ? next.ideas : [];
402
+ state.total = Number(next.total) || state.total;
403
+ if (batch.length === 0) { state.loading = false; break; }
404
+ state.ideas = state.ideas.concat(batch);
405
+ if (state.ideas.length >= CAP && state.ideas.length < state.total) {
406
+ state.capped = true;
407
+ state.loading = false;
408
+ } else if (state.ideas.length >= state.total) {
409
+ state.loading = false;
410
+ }
411
+ subLine();
412
+ paint();
413
+ }
414
+ paint();
415
+ }
416
+
417
+ main();
418
+ })();
@@ -0,0 +1,20 @@
1
+ {
2
+ "_": "THE ROOM (task 1004098 / BV2.R03) through modules/ui-design/kit/render.js. Every row on this page comes from ONE authenticated read, so against the kit's own stub it renders its signed-out / empty state — which is a state the floors must hold in and is rendered here deliberately. To look at the populated page, run it against the hall-preview harness, whose inbox__by-builder.json fixture carries one of each of the five objects plus a historical size and an unreadable brightness:\n\n node scripts/hall-preview/server.js --port 4615 --fixture-me\n node modules/ui-design/kit/render.js --states modules/hall-ui/public/thinking.states.json --base-in http://localhost:4615 --base-out http://localhost:4615",
3
+ "page": "/builders/thinking",
4
+ "surface": "hall-ui",
5
+ "stub": { "prefix": "/builders/" },
6
+ "modeQuery": false,
7
+ "ignoreRequests": ["/api/(gds|bongos)/"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
9
+ "states": {
10
+ "room": { "auth": true, "actions": [["wait", 900]], "expect": { "visible": ["#tr-figs", "#tr-body"] } },
11
+ "burning": {
12
+ "auth": true,
13
+ "_": "The filter strip is the page's only control, and the state it selects is the one a person opens this page for. clickIfVisible and not click: the tab is HIDDEN when the trail carries no stars, which is exactly what the kit's own stub produces, so a bare click would fail the state rather than render it.",
14
+ "actions": [["wait", 900], ["clickIfVisible", "[data-tab=\"star\"]"], ["wait", 200]],
15
+ "expect": { "visible": ["#tr-body"] }
16
+ },
17
+ "out": { "auth": false, "actions": [["wait", 600]] }
18
+ },
19
+ "reducedMotion": { "auth": true, "actions": [["wait", 900]] }
20
+ }
@@ -162,7 +162,7 @@
162
162
 
163
163
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
164
164
  chrome exists before the page script runs. -->
165
- <script src="/builders/shell.js?v=2026-09-20-coreupdate1"></script>
165
+ <script src="/builders/shell.js?v=2026-09-20-room1"></script>
166
166
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
167
167
  <script src="/builders/palette.js" defer></script>
168
168
  <!-- shared frontend toolkit (window.OTB) — loaded before watch.js, which pulls from it. -->
@@ -151,7 +151,7 @@
151
151
 
152
152
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
153
153
  time so the chrome exists before the deferred page scripts execute. -->
154
- <script src="/builders/shell.js?v=2026-09-20-coreupdate1"></script>
154
+ <script src="/builders/shell.js?v=2026-09-20-room1"></script>
155
155
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
156
156
  <script src="/builders/palette.js" defer></script>
157
157
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so work.js can pull from it. -->
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.927",
3
+ "version": "1.19.929",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.927",
9
+ "version": "1.19.929",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.927",
3
+ "version": "1.19.929",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -30,6 +30,16 @@ function coreVersionSafe() {
30
30
  try { return require(path.join(REPO_ROOT, 'src/module-api')).CORE_VERSION; } catch { return null; }
31
31
  }
32
32
 
33
+ // What an allowFail step returns when its command failed (task 1004128). It KEEPS the
34
+ // output: a caller that opts into allowFail needs the reason, and returning an empty
35
+ // string made every upgrade pre-flight report a generic "the dry run did not complete"
36
+ // while `sudo: unknown user …` sat in the journal. WITHHELD when the step is
37
+ // secret-bearing (a `shown` call site), where only an exit status may ever surface.
38
+ function softFailResult(e, secretBearing) {
39
+ if (secretBearing) return { ok: true, stdout: '', softFailed: true };
40
+ return { ok: true, stdout: (e.stdout || '').toString(), stderr: (e.stderr || '').toString(), softFailed: true };
41
+ }
42
+
33
43
  function numEnv(name, dflt) {
34
44
  const v = process.env[name];
35
45
  if (v == null || v === '') return dflt;
@@ -219,4 +229,5 @@ module.exports = {
219
229
  numEnv,
220
230
  oauthSecret,
221
231
  provisionerBotEmail,
232
+ softFailResult,
222
233
  };