@bongos/core 1.19.300 → 1.19.301

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.
@@ -26,6 +26,15 @@
26
26
  // here would be a second copy of a DIFFERENT rule — the exact mistake this file
27
27
  // is organised to avoid — so the gauge reports completeness and stops there.
28
28
  //
29
+ // TWO MODES, ONE FORM (task 1002930 / R154). Opened plainly, the composer FILES a
30
+ // new Full Idea. Opened as ?develop=<id> from the spark queue below, it DEVELOPS
31
+ // an existing spark: the spark's own words are shown above the fields, its title
32
+ // becomes a read-only fact rather than an editable input (developing an idea must
33
+ // not quietly rename it out from under its author), and filing calls
34
+ // POST /inbox/:id/develop instead of POST /inbox. The five fields, the meter, the
35
+ // validation and the draft behaviour are identical — which is the point of reusing
36
+ // this surface rather than building a second one that drifts from it.
37
+ //
29
38
  // DRAFTS ARE LOCAL. "A partial draft is not lost on navigation" is a done-when,
30
39
  // and a half-written thought is not something to put in the database: it has no
31
40
  // title yet, no grade, and nothing downstream should be able to see it. It lives
@@ -38,9 +47,17 @@
38
47
  const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
39
48
  const { escapeHtml, $, toast } = window.OTB;
40
49
 
41
- // One key, this page only. Versioned by template key at save time so a project
42
- // that changes its template (R156) cannot restore a draft shaped for the old one.
43
- const DRAFT_KEY = 'hall-full-idea-draft';
50
+ // One key per THING BEING WRITTEN. A new idea and a spark being developed are
51
+ // different drafts, and sharing one key would let a half-written new idea
52
+ // overwrite someone's development work (or restore itself into it). Versioned by
53
+ // template key at save time too, so a project that changes its template (R156)
54
+ // cannot restore a draft shaped for the old one.
55
+ const developId = () => {
56
+ const m = /[?&]develop=(\d+)\b/.exec(location.search);
57
+ return m ? m[1] : null;
58
+ };
59
+ const DEVELOP_ID = developId();
60
+ const DRAFT_KEY = DEVELOP_ID ? `hall-full-idea-draft:develop:${DEVELOP_ID}` : 'hall-full-idea-draft';
44
61
  const SCORE_DEBOUNCE_MS = 300;
45
62
  const DRAFT_DEBOUNCE_MS = 500;
46
63
 
@@ -187,7 +204,9 @@
187
204
  // problem, not a capture error). Matching that exactly means the button never
188
205
  // promises an outcome the server will refuse, and never blocks one it accepts.
189
206
  const missingRequired = template.fields.some((f) => f.required && !fieldEl(f.key).value.trim());
190
- $('#fi-submit').disabled = filing || missingRequired || !titleEl().value.trim();
207
+ // In develop mode the title is the existing idea's and is not editable, so it
208
+ // cannot be a reason to keep the button off.
209
+ $('#fi-submit').disabled = filing || missingRequired || (!DEVELOP_ID && !titleEl().value.trim());
191
210
  }
192
211
 
193
212
  async function scoreNow() {
@@ -209,7 +228,7 @@
209
228
  // round-trip: it depends only on what is typed.
210
229
  if (lastScore) renderScore(lastScore);
211
230
  else $('#fi-submit').disabled = filing
212
- || !titleEl().value.trim()
231
+ || (!DEVELOP_ID && !titleEl().value.trim())
213
232
  || template.fields.some((f) => f.required && !fieldEl(f.key).value.trim());
214
233
  }
215
234
 
@@ -251,14 +270,22 @@
251
270
  showErr('');
252
271
  $('#fi-submit').disabled = true;
253
272
 
254
- const r = await api.inbox.postInbox({
255
- body: {
256
- title: titleEl().value.trim(),
257
- grade: 'full',
258
- template_key: template.key,
259
- field_values: collect(),
260
- },
261
- });
273
+ // The one behavioural fork between the two modes. Developing is an act on an
274
+ // EXISTING idea, so it carries no title and no grade: the row already has
275
+ // both, and R146 preserves them along with its author.
276
+ const r = DEVELOP_ID
277
+ ? await api.inbox.postInboxIdDevelop({
278
+ id: DEVELOP_ID,
279
+ body: { field_values: collect(), template_key: template.key },
280
+ })
281
+ : await api.inbox.postInbox({
282
+ body: {
283
+ title: titleEl().value.trim(),
284
+ grade: 'full',
285
+ template_key: template.key,
286
+ field_values: collect(),
287
+ },
288
+ });
262
289
 
263
290
  filing = false;
264
291
  if (!r.ok) {
@@ -275,11 +302,19 @@
275
302
  // grade write did not, and says so. Repeat that here instead of claiming a
276
303
  // Full Idea was stored when the response says otherwise.
277
304
  const landedFull = idea && idea.grade === 'full';
278
- toast(landedFull
279
- ? `Filed as a Full Idea${score == null ? '' : ` — ${score}/100`}.`
280
- : 'Filed, but stored as a quick idea — the grade could not be written.');
305
+ if (DEVELOP_ID) {
306
+ // The split is the developer's business: they earned a share of it and the
307
+ // author kept theirs, and saying so is how the handoff stops feeling like
308
+ // giving work away (R147 / ADR 0185).
309
+ const credited = r.data && r.data.idea && r.data.idea.ideator_credit;
310
+ toast(`Developed${score == null ? '' : ` — ${score}/100`}${credited ? `. ${credited} credits split with its author.` : '.'}`);
311
+ } else {
312
+ toast(landedFull
313
+ ? `Filed as a Full Idea${score == null ? '' : ` — ${score}/100`}.`
314
+ : 'Filed, but stored as a quick idea — the grade could not be written.');
315
+ }
281
316
 
282
- titleEl().value = '';
317
+ if (!DEVELOP_ID) titleEl().value = '';
283
318
  for (const f of template.fields) fieldEl(f.key).value = '';
284
319
  lastScore = null;
285
320
  renderScore(null);
@@ -308,6 +343,79 @@
308
343
  // boot
309
344
  // ---------------------------------------------------------------------------
310
345
 
346
+ // ---------------------------------------------------------------------------
347
+ // the spark queue (task 1002930 / R154)
348
+ // ---------------------------------------------------------------------------
349
+
350
+ const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
351
+
352
+ function renderQueue(sparks, count) {
353
+ const host = $('#fi-queue');
354
+ if (!sparks || sparks.length === 0) {
355
+ // An answer, not a blank space — and it says how one gets here, since the
356
+ // only way to fill this queue is for an author to offer something up.
357
+ host.innerHTML = '<p class="fi-queue-empty">Nothing is waiting. A spark appears here when its author offers a quick idea up for development.</p>';
358
+ return;
359
+ }
360
+ const total = Number.isInteger(count) ? count : sparks.length;
361
+ const more = total > sparks.length ? `<p class="fi-note">Showing ${sparks.length} of ${total}.</p>` : '';
362
+ host.innerHTML = `<div class="fi-queue">${sparks.map((sp) => {
363
+ const who = sp.filed_by_login ? `@${escapeHtml(sp.filed_by_login)}` : 'an unnamed builder';
364
+ const when = sp.captured_at ? ` · ${escapeHtml(String(sp.captured_at).slice(0, 10))}` : '';
365
+ return `
366
+ <div class="fi-spark">
367
+ <p class="fi-spark__title"><a href="${hallBase()}/idea/${encodeURIComponent(sp.id)}">${escapeHtml(sp.title || `Idea ${sp.id}`)}</a></p>
368
+ <span class="fi-spark__who">had by ${who}${when}</span>
369
+ <a class="fi-spark__go btn btn--ghost" href="?develop=${encodeURIComponent(sp.id)}">Develop this →</a>
370
+ </div>`;
371
+ }).join('')}</div>${more}`;
372
+ }
373
+
374
+ async function loadQueue() {
375
+ const r = await api.inbox.getInboxSparks({});
376
+ // The queue is secondary to the composer: an outage here must not stop
377
+ // someone writing, so it degrades to a line rather than an error state.
378
+ if (!r.ok || !r.data) {
379
+ $('#fi-queue').innerHTML = '<p class="fi-queue-empty">Could not load the queue just now.</p>';
380
+ return;
381
+ }
382
+ renderQueue(r.data.sparks, r.data.count);
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // develop mode
387
+ // ---------------------------------------------------------------------------
388
+
389
+ // Show the spark's own words above the fields, and make the title a FACT rather
390
+ // than an input. Returns false when the idea cannot be developed, so boot can
391
+ // say why instead of presenting a form that will 409.
392
+ function enterDevelopMode(idea) {
393
+ if (idea.grade !== 'spark') {
394
+ $('#fi-sub').textContent = idea.grade === 'full'
395
+ ? `Idea ${idea.id} has already been developed into a Full Idea.`
396
+ : `Idea ${idea.id} is not a Big Idea Spark — only its author can offer it up for development.`;
397
+ $('#fi-form').hidden = true;
398
+ return false;
399
+ }
400
+ $('#fi-develop').hidden = false;
401
+ $('#fi-develop-title').textContent = idea.title || `Idea ${idea.id}`;
402
+ $('#fi-develop-body').textContent = String(idea.body_md || '').trim();
403
+ $('#fi-develop-who').textContent = idea.filed_by_login
404
+ ? `had by @${idea.filed_by_login}${idea.captured_at ? ` · ${String(idea.captured_at).slice(0, 10)}` : ''} — they keep the credit for the thought`
405
+ : 'they keep the credit for the thought';
406
+ // The title is the existing idea's. Shown, not editable: developing an idea
407
+ // must not quietly rename it out from under its author.
408
+ const t = titleEl();
409
+ t.value = idea.title || '';
410
+ t.readOnly = true;
411
+ t.tabIndex = -1;
412
+ $('#fi-title-label').hidden = true;
413
+ t.hidden = true;
414
+ $('#fi-submit').textContent = 'Develop this idea';
415
+ $('#fi-sub').textContent = 'Answer the five questions and this spark becomes a Full Idea.';
416
+ return true;
417
+ }
418
+
311
419
  async function main() {
312
420
  const r = await api.inbox.getInboxTemplate({});
313
421
  if (!r.ok || !r.data || !r.data.template) {
@@ -326,6 +434,22 @@
326
434
  $('#fi-form').addEventListener('submit', file);
327
435
  $('#fi-clear').addEventListener('click', discard);
328
436
 
437
+ // The queue loads on every visit, develop mode or not: having just picked one
438
+ // up, seeing what else is waiting is the natural next thing.
439
+ loadQueue();
440
+
441
+ if (DEVELOP_ID) {
442
+ const d = await api.inbox.getInboxId({ id: DEVELOP_ID });
443
+ if (!d.ok || !d.data || !d.data.idea) {
444
+ $('#fi-sub').textContent = `Could not load idea ${DEVELOP_ID}.`;
445
+ $('#fi-form').hidden = true;
446
+ return;
447
+ }
448
+ if (!enterDevelopMode(d.data.idea)) return;
449
+ // Re-run the button rule now that the title requirement is lifted.
450
+ onEdit();
451
+ }
452
+
329
453
  if (usable) {
330
454
  note('Draft restored.');
331
455
  scoreNow();
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.300",
3
+ "version": "1.19.301",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.300",
9
+ "version": "1.19.301",
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.300",
3
+ "version": "1.19.301",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -1027,8 +1027,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1027
1027
  // landed since 1.19.298 with no explicit bump. run 32600322268. (task 1002620)
1028
1028
  // 1.19.300 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1029
1029
  // landed since 1.19.299 with no explicit bump. run 32601521458. (task 1002620)
1030
+ // 1.19.301 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1031
+ // landed since 1.19.300 with no explicit bump. run 32602146266. (task 1002620)
1030
1032
  // ---------------------------------------------------------------------------
1031
- const CORE_VERSION = '1.19.300'; // CI auto-patch carrier (ADR 0161); see changelog above
1033
+ const CORE_VERSION = '1.19.301'; // CI auto-patch carrier (ADR 0161); see changelog above
1032
1034
 
1033
1035
  // A namespaced logger so a module's log lines are attributable + consistent.
1034
1036
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -41,12 +41,14 @@ const TEMPLATE = {
41
41
 
42
42
  // Boot idea.js against a stub DOM + a stub client that answers with `idea`, and
43
43
  // return the HTML it wrote into #idea-body.
44
- function render(idea, { template = TEMPLATE, templateFails = false, ideaStatus = 200 } = {}) {
44
+ function render(idea, { template = TEMPLATE, templateFails = false, ideaStatus = 200, viewer = null, sparkResult = null } = {}) {
45
45
  const els = new Map();
46
46
  const makeEl = () => ({
47
47
  dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
48
48
  classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
49
- setAttribute() {}, getAttribute() { return null; }, addEventListener() {},
49
+ setAttribute() {}, getAttribute() { return null; },
50
+ _handlers: {},
51
+ addEventListener(type, fn) { this._handlers[type] = fn; },
50
52
  querySelector() { return makeEl(); }, querySelectorAll() { return []; }, appendChild() {},
51
53
  set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
52
54
  });
@@ -71,7 +73,18 @@ function render(idea, { template = TEMPLATE, templateFails = false, ideaStatus =
71
73
  if (templateFails) return { status: 500, ok: false, data: null };
72
74
  return { status: 200, ok: true, data: { template } };
73
75
  },
76
+ // R154's mark control. `sparkResult` lets a test drive the failure path.
77
+ postInboxIdSpark: async (args) => {
78
+ calls.push(['spark', args]);
79
+ return sparkResult || { status: 200, ok: true, data: { idea: { ...idea, grade: 'spark' }, sparked: true } };
80
+ },
81
+ deleteInboxIdSpark: async (args) => {
82
+ calls.push(['unspark', args]);
83
+ return sparkResult || { status: 200, ok: true, data: { idea: { ...idea, grade: 'quick' }, sparked: false } };
84
+ },
74
85
  },
86
+ // The page reads /me for the author-only control (R154).
87
+ me: { getMe: async () => { calls.push(['me', null]); return { status: 200, ok: true, data: { builder: viewer } }; } },
75
88
  };
76
89
 
77
90
  const windowObj = {
@@ -92,7 +105,19 @@ function render(idea, { template = TEMPLATE, templateFails = false, ideaStatus =
92
105
  vm.createContext(sandbox);
93
106
  vm.runInContext(SRC, sandbox, { filename: 'idea.js' });
94
107
 
95
- return { html: () => documentObj.getElementById('idea-body').innerHTML, calls, documentObj };
108
+ return {
109
+ html: () => documentObj.getElementById('idea-body').innerHTML,
110
+ calls,
111
+ documentObj,
112
+ // The mark button is written via innerHTML, so the stub DOM has no real
113
+ // element for it — idea.js looks it up with $('#ig-mark-btn'), which resolves
114
+ // to the memoised stub, so its captured click handler is the click.
115
+ clickMark: () => {
116
+ const btn = documentObj.getElementById('ig-mark-btn');
117
+ return btn._handlers.click ? btn._handlers.click() : null;
118
+ },
119
+ markBtn: () => documentObj.getElementById('ig-mark-btn'),
120
+ };
96
121
  }
97
122
 
98
123
  // The render is async (two awaits at most), so let the microtask queue drain.
@@ -176,7 +201,13 @@ await test('a QUICK idea shows its body and NOT one of the five questions', asyn
176
201
  await test('a quick idea costs ONE request — the labels are only needed for a full one', async () => {
177
202
  const r = render({ id: '5', title: 't', status: 'open', body_md: 'b', grade: 'quick', captured_by: '9' });
178
203
  await settle();
179
- assert.deepEqual(r.calls.map((c) => c[0]), ['idea'], 'no template fetch for a quick idea');
204
+ // Asserted as "no TEMPLATE call", not "exactly one call": R154 added a /me read
205
+ // for the author-only mark control, which is a legitimate second request. The
206
+ // invariant was always about the template — the expensive one a quick idea has
207
+ // no use for — so it is asserted directly rather than via a total count.
208
+ const kinds = r.calls.map((c) => c[0]);
209
+ assert.ok(!kinds.includes('template'), 'no template fetch for a quick idea');
210
+ assert.ok(kinds.includes('idea'), 'the idea itself is read');
180
211
  });
181
212
 
182
213
  await test('a SPARK shows its body, no skeleton, and says it is open for development', async () => {
@@ -267,5 +298,100 @@ await test('a 404 says which idea is missing rather than failing blank', async (
267
298
  assert.match(r.html(), /No idea 99/);
268
299
  });
269
300
 
301
+ // --- R154: the mark control is AUTHOR-ONLY ------------------------------
302
+
303
+ const AUTHOR = { id: '9', github_login: 'thinker' };
304
+ const OTHER = { id: '77', github_login: 'someone-else' };
305
+ const quick = {
306
+ id: '5', title: 'a quick thought', status: 'open', body_md: 'just the thought',
307
+ captured_by: '9', captured_at: '2026-08-01T00:00:00Z', grade: 'quick',
308
+ filed_by_login: 'thinker',
309
+ };
310
+
311
+ await test('the AUTHOR of an open quick idea is offered the mark', async () => {
312
+ const r = render(quick, { viewer: AUTHOR });
313
+ await settle();
314
+ assert.match(r.html(), /Mark as a Big Idea Spark/);
315
+ });
316
+
317
+ await test('a NON-author is not — the wall is the route, this is the cosmetic half', async () => {
318
+ const r = render(quick, { viewer: OTHER });
319
+ await settle();
320
+ assert.doesNotMatch(r.html(), /Mark as a Big Idea Spark/);
321
+ assert.doesNotMatch(r.html(), /Withdraw the spark/);
322
+ });
323
+
324
+ await test('a signed-out or unknown viewer is offered nothing', async () => {
325
+ const r = render(quick, { viewer: null });
326
+ await settle();
327
+ assert.doesNotMatch(r.html(), /Mark as a Big Idea Spark/);
328
+ });
329
+
330
+ await test('an author looking at their own SPARK is offered the withdrawal', async () => {
331
+ const r = render({ ...quick, grade: 'spark' }, { viewer: AUTHOR });
332
+ await settle();
333
+ assert.match(r.html(), /Withdraw the spark mark/);
334
+ assert.doesNotMatch(r.html(), /Mark as a Big Idea Spark/);
335
+ });
336
+
337
+ await test('no control on a FULL idea — R144 refuses it, so offering it teaches the rule wrong', async () => {
338
+ const r = render({ ...fullIdea, captured_by: '9' }, { viewer: AUTHOR });
339
+ await settle();
340
+ assert.doesNotMatch(r.html(), /Big Idea Spark<\/button>|Mark as a Big Idea Spark/);
341
+ });
342
+
343
+ await test('no control on a CLOSED idea — marking requires status=open', async () => {
344
+ for (const status of ['promoted', 'discarded', 'merged']) {
345
+ const r = render({ ...quick, status }, { viewer: AUTHOR });
346
+ await settle();
347
+ assert.doesNotMatch(r.html(), /Mark as a Big Idea Spark/, `status=${status}`);
348
+ }
349
+ });
350
+
351
+ await test('marking calls the spark route and re-renders from the SERVER response', async () => {
352
+ const r = render(quick, { viewer: AUTHOR });
353
+ await settle();
354
+ await r.clickMark();
355
+ await settle();
356
+ assert.ok(r.calls.some((c) => c[0] === 'spark'), 'POST /inbox/:id/spark was called');
357
+ // Re-rendered from what came back, not from a locally flipped flag.
358
+ assert.match(r.html(), /Big Idea Spark/);
359
+ assert.match(r.html(), /Withdraw the spark mark/);
360
+ });
361
+
362
+ await test('withdrawing calls the DELETE side', async () => {
363
+ const r = render({ ...quick, grade: 'spark' }, { viewer: AUTHOR });
364
+ await settle();
365
+ await r.clickMark();
366
+ await settle();
367
+ assert.ok(r.calls.some((c) => c[0] === 'unspark'));
368
+ assert.match(r.html(), /Quick idea/);
369
+ });
370
+
371
+ await test('a refusal shows the server\'s reason, in the author\'s terms', async () => {
372
+ const { envelope } = require('../src/bongos/api-errors.js');
373
+ const cases = [
374
+ ['not_your_idea', /Only the builder who filed this idea/],
375
+ ['not_a_quick_idea', /already a Full Idea/],
376
+ ['idea_not_open', /no longer open/],
377
+ ];
378
+ for (const [code, expect] of cases) {
379
+ const r = render(quick, { viewer: AUTHOR, sparkResult: { status: 409, ok: false, data: envelope(code) } });
380
+ await settle();
381
+ await r.clickMark();
382
+ await settle();
383
+ assert.match(r.documentObj.getElementById('ig-mark-err').textContent, expect, code);
384
+ }
385
+ });
386
+
387
+ await test('a failed mark re-enables the button rather than dead-ending', async () => {
388
+ const { envelope } = require('../src/bongos/api-errors.js');
389
+ const r = render(quick, { viewer: AUTHOR, sparkResult: { status: 500, ok: false, data: envelope('boom') } });
390
+ await settle();
391
+ await r.clickMark();
392
+ await settle();
393
+ assert.equal(r.markBtn().disabled, false);
394
+ });
395
+
270
396
  console.log(`\nidea_detail_page: ${passed} passed, ${failed} failed`);
271
397
  if (failed > 0) process.exit(1);
@@ -242,5 +242,56 @@ await test('neither read touches the database', async () => {
242
242
  assert.equal(touched, 0, 'both are pure — which is what makes a call per keystroke reasonable');
243
243
  });
244
244
 
245
+ // --- R154: the spark queue + develop mode, as source invariants ----------
246
+ //
247
+ // The composer's runtime is exercised through the page's own DOM in
248
+ // tests/idea_spark_hall.mjs; these are the invariants that are cheapest and most
249
+ // durable to assert over the source, because each one is a rule about WHICH
250
+ // endpoint gets called and a mistake in it silently does the wrong write.
251
+
252
+ await test('develop mode files through the DEVELOP route, not a new capture', () => {
253
+ assert.match(IDEAS_JS, /postInboxIdDevelop/, 'developing calls POST /inbox/:id/develop');
254
+ assert.match(IDEAS_JS, /postInbox\b/, 'and filing a new idea still calls POST /inbox');
255
+ // The fork must be on the develop id, not on anything guessable from the form.
256
+ assert.match(IDEAS_JS, /DEVELOP_ID\s*\n?\s*\?\s*await api\.inbox\.postInboxIdDevelop/, 'the fork keys off the develop id');
257
+ });
258
+
259
+ await test('a develop POST carries no title and no grade', () => {
260
+ // The row already has both, and R146 preserves them along with its author —
261
+ // sending either would be this surface asserting authority over someone else's
262
+ // idea. Scoped to the develop branch so the new-capture body is unaffected.
263
+ const from = IDEAS_JS.indexOf('postInboxIdDevelop');
264
+ const body = IDEAS_JS.slice(from, IDEAS_JS.indexOf('})', from));
265
+ assert.ok(!/title:/.test(body), 'no title in the develop body');
266
+ assert.ok(!/grade:/.test(body), 'no grade in the develop body');
267
+ assert.match(body, /field_values/);
268
+ });
269
+
270
+ await test('the develop draft is keyed per idea, so it cannot collide with a new one', () => {
271
+ // Sharing one key would let a half-written new idea overwrite development work.
272
+ assert.match(IDEAS_JS, /develop:\$\{DEVELOP_ID\}/);
273
+ });
274
+
275
+ await test('the queue is read from the R145 endpoint, not rebuilt from the inbox', () => {
276
+ assert.match(IDEAS_JS, /getInboxSparks/);
277
+ // And it links each row at the R153 page rather than the overlay.
278
+ assert.match(IDEAS_JS, /\/idea\/\$\{encodeURIComponent\(sp\.id\)\}/);
279
+ });
280
+
281
+ await test('the develop block ships HIDDEN — plain mode must not flash it', () => {
282
+ // The runtime test cannot see this (its stub DOM does not parse markup), so the
283
+ // attribute is pinned here, over the HTML itself.
284
+ assert.match(IDEAS_HTML, /id="fi-develop"[^>]*\shidden/, '#fi-develop carries the hidden attribute');
285
+ });
286
+
287
+ await test('the queue never blocks the composer', () => {
288
+ // The composer is the page's job; the queue is context. An outage in the
289
+ // secondary read must not take the primary surface down with it.
290
+ const from = IDEAS_JS.indexOf('async function loadQueue');
291
+ const fn = IDEAS_JS.slice(from, IDEAS_JS.indexOf('\n }', from));
292
+ assert.match(fn, /if \(!r\.ok/, 'it handles its own failure');
293
+ assert.ok(!/throw/.test(fn), 'and never throws into boot');
294
+ });
295
+
245
296
  console.log(`\nidea_hall_composer: ${passed} passed, ${failed} failed`);
246
297
  if (failed > 0) process.exit(1);