@bongos/core 1.24.74 → 1.24.76

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 (43) hide show
  1. package/.bongos-core.json +77 -42
  2. package/.claude/skills/blocker-review/SKILL.md +10 -0
  3. package/clients/bongos-client/index.d.ts +1 -1
  4. package/docs/api/openapi.json +6 -2
  5. package/docs/api-reference.md +2 -2
  6. package/docs/copy-inventory.md +55 -47
  7. package/docs/copy-registry.json +143 -71
  8. package/docs/module-api-changelog.md +2 -0
  9. package/docs/packs/engineer.md +2 -2
  10. package/docs/page-inventory.json +2 -0
  11. package/docs/page-readings.json +10 -10
  12. package/modules/government/board.js +10 -4
  13. package/modules/government/db.js +7 -5
  14. package/modules/government/genesis-close.js +45 -0
  15. package/modules/government/migrations/government_038_vote_cast_via.sql +23 -0
  16. package/modules/government/routes/government.js +4 -5
  17. package/modules/hall-ui/public/board-room.js +1 -0
  18. package/modules/hall-ui/public/deploy.html +1 -1
  19. package/modules/hall-ui/public/deploy.js +56 -5
  20. package/modules/hall-ui/public/genesis-home.js +7 -12
  21. package/modules/hall-ui/public/home-city.js +7 -2
  22. package/modules/hall-ui/public/home-idea-box.css +33 -0
  23. package/modules/hall-ui/public/home-idea-box.js +106 -0
  24. package/modules/hall-ui/public/index.html +2 -0
  25. package/modules/hall-ui/public/project-settings.css +15 -0
  26. package/modules/hall-ui/records/home-city.md +21 -0
  27. package/modules/ideas/routes/blockers.js +47 -1
  28. package/modules/onboarding/genesis-home.js +65 -8
  29. package/modules/onboarding/routes/onboarding.js +15 -1
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/release-notes.json +18 -0
  33. package/scripts/bongos/blocker-recheck.js +111 -0
  34. package/scripts/bongos/government-walk.js +4 -4
  35. package/scripts/gds/blocker-recheck.js +7 -0
  36. package/scripts/hall-preview/server.js +1 -0
  37. package/src/module-api.js +1 -1
  38. package/tests/deploy_page_projects.mjs +49 -0
  39. package/tests/genesis_home.mjs +86 -1
  40. package/tests/government_agent_asks.mjs +92 -2
  41. package/tests/government_board_genesis_stage.mjs +83 -0
  42. package/tests/government_seed.mjs +1 -0
  43. package/tests/home_idea_box.mjs +178 -0
@@ -169,6 +169,8 @@ async function call(method, p, body, source) {
169
169
  let json = null; try { json = await res.json(); } catch { /* none */ }
170
170
  return { ok: res.ok, status: res.status, data: json };
171
171
  }
172
+ // What an agent opened or ran that showed a person is needed (task 1005120).
173
+ const CHECKED = 'Opened the Plaid dashboard OAuth page: the Chase registration form is unsubmitted and needs the owner’s login.';
172
174
  // The helper's client: the CLI's request shape, against these routers.
173
175
  const client = { request: (method, p, opts = {}) => call(method, p, opts.body, 'cli') };
174
176
 
@@ -209,8 +211,8 @@ test('an agent that files prose is refused, rewrites with the helper, and files
209
211
  assert.match(passed.lines[0], /^✓ This ask has the shape\. As a person reads it:$/);
210
212
  assert.equal(passed.lines.slice(2).join('\n'), asks.cardText(OVERRIDE_CARD));
211
213
 
212
- // 5. and files, stored against the blocker
213
- const ok = await call('POST', '/blockers', rewrite, 'cli');
214
+ // 5. and files, stored against the blocker (with what the session checked: task 1005120)
215
+ const ok = await call('POST', '/blockers', { ...rewrite, checked: CHECKED }, 'cli');
214
216
  assert.equal(ok.status, 201, JSON.stringify(ok.data));
215
217
  assert.equal(store.get(`blockers:${ok.data.blocker.id}`).question, OVERRIDE_CARD.question);
216
218
  assert.equal(store.get(`blockers:${ok.data.blocker.id}`).detail, OVERRIDE.detail.trim(), 'the prose rides along');
@@ -231,6 +233,94 @@ test('every source an agent files with is held to it; the hall and the templated
231
233
  }
232
234
  });
233
235
 
236
+ // ── A HIGHER BAR FOR FILING, AND A RE-CHECK BEFORE ASKING AGAIN (task 1005120) ──
237
+ //
238
+ // A project's blocker asked its owner to "apply to Plaid for OAuth" for days after the
239
+ // project's own shipped work had found there was nothing to apply for. It was filed on an
240
+ // inference (one bank worked, one did not) when the page that settled it was one click
241
+ // away. So an agent filing by hand now says what it CHECKED that showed a person is
242
+ // needed, and that sentence rides in the blocker's body where every later reader sees it.
243
+
244
+ test('an agent files a blocker only with what it checked; the check rides in the body', async () => {
245
+ const before = filed.length;
246
+ const card = { title: OVERRIDE.title, source: 'task', source_ref: '1005120-a', ask: OVERRIDE_CARD };
247
+
248
+ // no check at all: refused, nothing filed, and told what to send
249
+ const bare = await call('POST', '/blockers', card, 'cli');
250
+ assert.equal(bare.status, 400);
251
+ assert.equal(bare.data.error.code, 'checked_required');
252
+ assert.match(bare.data.error.message, /what you opened or ran/);
253
+ assert.equal(filed.length, before, 'nothing was filed');
254
+
255
+ // a hunch is not a check: too short to name what was looked at
256
+ const hunch = await call('POST', '/blockers', { ...card, checked: 'probably needed' }, 'cli');
257
+ assert.equal(hunch.status, 400);
258
+ assert.equal(hunch.data.error.code, 'checked_required');
259
+ assert.equal(filed.length, before, 'nothing was filed');
260
+
261
+ // with the check: filed, and the check is written into the blocker for its next reader
262
+ const ok = await call('POST', '/blockers', { ...card, body_md: 'Long detail.', checked: CHECKED }, 'cli');
263
+ assert.equal(ok.status, 201, JSON.stringify(ok.data));
264
+ assert.match(ok.data.blocker.body_md, /^Long detail\.\n\n\*\*Checked before filing \(by @someone\):\*\* Opened the Plaid dashboard/,
265
+ 'the check rides in the body, tracked back to the filer');
266
+ });
267
+
268
+ test('the check floor is exactly 30 characters, trimmed', async () => {
269
+ const card = { title: OVERRIDE.title, source: 'task', ask: OVERRIDE_CARD };
270
+ const short = await call('POST', '/blockers', { ...card, source_ref: '1005120-29', checked: ` ${'x'.repeat(29)} ` }, 'cli');
271
+ assert.equal(short.status, 400, '29 characters (padding trimmed) is below the floor');
272
+ assert.equal(short.data.error.code, 'checked_required');
273
+ assert.equal(short.data.error.details.min_length, 30);
274
+ const exact = await call('POST', '/blockers', { ...card, source_ref: '1005120-30', checked: 'x'.repeat(30) }, 'cli');
275
+ assert.equal(exact.status, 201, '30 characters is enough');
276
+ });
277
+
278
+ test('a body that the check line pushes past the cap is refused, not silently grown', async () => {
279
+ const before = filed.length;
280
+ const r = await call('POST', '/blockers', { title: OVERRIDE.title, source: 'task', source_ref: '1005120-long', ask: OVERRIDE_CARD,
281
+ body_md: 'y'.repeat(api.LIMITS.DESCRIPTION), checked: CHECKED }, 'cli');
282
+ assert.equal(r.status, 400);
283
+ assert.equal(r.data.error.code, 'body_too_long');
284
+ assert.equal(filed.length, before, 'nothing was filed');
285
+ });
286
+
287
+ test('the hall and the templated scripts are not held to the check', async () => {
288
+ assert.equal((await call('POST', '/blockers', { title: 'A person typed this' }, 'web')).status, 201);
289
+ for (const source of blockersRoutes.TEMPLATED_SOURCES) {
290
+ const r = await call('POST', '/blockers', { title: `Checked-free ${source}`, source, source_ref: `c-${source}` }, 'cli');
291
+ assert.equal(r.status, 201, source);
292
+ }
293
+ });
294
+
295
+ // The re-check the daily review runs (scripts/bongos/blocker-recheck.js): the shipped work
296
+ // since a blocker was filed that shares its subject — the case that went unread for days.
297
+ const recheck = require(path.join(ROOT, 'scripts', 'bongos', 'blocker-recheck.js'));
298
+ const PLAID = { id: '1', title: 'Chase and Bank of America need OAuth registration on toast\'s Plaid account',
299
+ body_md: 'Apply to Plaid for OAuth; approval takes weeks.', created_at: '2026-11-06T10:00:00Z' };
300
+
301
+ test('the re-check finds the shipped work that answered a blocker, and only work after it', () => {
302
+ const tasks = [
303
+ { id: 7, short_name: 'plaid oauth check', status: 'shipped', shipped_at: '2026-11-07T12:00:00Z',
304
+ title: 'Check Plaid OAuth access for Chase', value_summary: 'Plaid already granted OAuth; Chase works, Bank of America needs one settings field.' },
305
+ { id: 5, status: 'shipped', shipped_at: '2026-11-05T12:00:00Z', title: 'Plaid OAuth groundwork for Chase', value_summary: '' },
306
+ { id: 8, status: 'active', shipped_at: null, title: 'Plaid OAuth for Chase', value_summary: '' },
307
+ { id: 9, status: 'shipped', shipped_at: '2026-11-08T12:00:00Z', title: 'Landing page spacing', value_summary: 'Tighter hero.' },
308
+ ];
309
+ const hits = recheck.relatedShipped(PLAID, tasks);
310
+ assert.deepEqual(hits.map((h) => h.id), [7], 'shipped after filing, on the subject; not before, not unshipped, not unrelated');
311
+ assert.ok(hits[0].shared.includes('plaid') && hits[0].shared.includes('oauth'), 'it says which words matched');
312
+ const lines = recheck.reportLines(PLAID, hits).join('\n');
313
+ assert.match(lines, /task 7 - plaid oauth check/, 'named as task N - name, never a bare number');
314
+ assert.match(lines, /read these before asking again/);
315
+ });
316
+
317
+ test('one shared word is not enough, and no match never claims the blocker still holds', () => {
318
+ const hits = recheck.relatedShipped(PLAID, [{ id: 3, status: 'shipped', shipped_at: '2026-11-08T00:00:00Z', title: 'Plaid logo swap', value_summary: '' }]);
319
+ assert.deepEqual(hits, []);
320
+ assert.match(recheck.reportLines(PLAID, hits).join('\n'), /not proof it still holds/);
321
+ assert.deepEqual(recheck.relatedShipped({ ...PLAID, created_at: 'not a date' }, []), [], 'an unreadable filing date re-checks nothing');
322
+ });
323
+
234
324
  test('the helper checks locally, and says so, when the server cannot answer', async () => {
235
325
  const offline = { request: async () => { throw new Error('ECONNREFUSED'); } };
236
326
  const r = await askCli.check(writeCard('slop2.json', { question: 'Can you look at the deploy and tell me what you think about it all?' }), { client: offline });
@@ -303,3 +303,86 @@ test('the port carries the open and the status; the status route is pinned on th
303
303
  const pins = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'), 'utf8');
304
304
  assert.match(pins, /'GET \/government\/board\/genesis-stages': 'perm:board\.vote\.cast'/);
305
305
  });
306
+
307
+ // ── task 1005121: closing a stage is ONE call ────────────────────────────────
308
+
309
+ const { closeGenesisStage } = require(path.join(GOV, 'genesis-close.js'));
310
+
311
+ test('task 1005121: under Monarchy / BDFL closeGenesisStage opens, confirms and CLOSES in one call, and records HOW', async () => {
312
+ const votes = [];
313
+ let karma = 0;
314
+ const passed = await capturing('board.genesis_stage.passed', () => withDb({
315
+ getTaskStatuses: async () => [{ id: '101', status: 'shipped' }],
316
+ openBoardItem: async (args) => ({ ...stageItem(), constitution: MONARCHY, opened_by: args.openedBy }),
317
+ getBoardItemForVote: async () => stageItem(),
318
+ builderHoldsRankActive: async (id) => id === FOUNDER,
319
+ getActiveBuildersWithRank: async () => [member(FOUNDER, 'founder')],
320
+ getBoardItemAuthor: async (item) => item.opened_by,
321
+ insertBoardVote: async (row) => { const v = { ...row, voter_id: row.voterId, cast_via: row.castVia }; votes.push(v); return v; },
322
+ insertVoteKarma: async () => { karma += 1; },
323
+ getVotesForItem: async () => votes,
324
+ closeBoardItem: async ({ outcome }) => ({ ...stageItem(), closed_at: 'T', outcome }),
325
+ insertAuthorRatifyKarma: async () => true,
326
+ }, async () => {
327
+ const out = await closeGenesisStage({ stageKey: 'charter', taskIds: [101], closedBy: FOUNDER });
328
+ assert.equal(out.closed, true, 'the answer says CLOSED — no second request needed');
329
+ assert.equal(out.stage.key, 'charter');
330
+ assert.equal(out.cast.cast_via, 'stage_close');
331
+ }));
332
+ assert.equal(votes.length, 1);
333
+ assert.equal(votes[0].castVia, 'stage_close', 'the vote row records it was a confirm given by closing the stage');
334
+ assert.equal(votes[0].direction, 'yes');
335
+ assert.equal(karma, 0, 'the confirm decided nothing the ask had not — no vote karma');
336
+ assert.equal(passed.length, 1, 'the genesis home hears the pass');
337
+ });
338
+
339
+ test('task 1005121: where one yes cannot carry it, nothing is cast and the answer says what the sitting waits on', async () => {
340
+ let inserted = 0;
341
+ await withDb({
342
+ getTaskStatuses: async () => [{ id: '101', status: 'shipped' }],
343
+ openBoardItem: async () => ({ ...stageItem({ constitution: CONSENT }) }),
344
+ insertBoardVote: async () => { inserted += 1; return null; },
345
+ listLatestGenesisStageItems: async () => [stageItem({ constitution: CONSENT })],
346
+ getVotesForItems: async () => [],
347
+ getActiveBuildersWithRank: async () => [member(FOUNDER, 'founder'), member(AVERY, 'avery')],
348
+ }, async () => {
349
+ const out = await closeGenesisStage({ stageKey: 'charter', taskIds: [101], closedBy: FOUNDER });
350
+ assert.equal(out.closed, false);
351
+ assert.equal(out.opened, true);
352
+ assert.equal(out.item.id, '90');
353
+ assert.equal(out.status.state, 'sitting');
354
+ assert.deepEqual(out.status.waiting_on.map((m) => m.builder_id), [FOUNDER, AVERY]);
355
+ assert.ok(out.status.needs, 'the rule, in words');
356
+ });
357
+ assert.equal(inserted, 0, 'no vote is cast on anyone\'s behalf under a rule one person cannot carry');
358
+ });
359
+
360
+ test('task 1005121: a refusal from the gate passes straight through, nothing opened', async () => {
361
+ await withDb({
362
+ getTaskStatuses: async () => [{ id: '101', status: 'active' }],
363
+ openBoardItem: async () => { throw new Error('must not be reached'); },
364
+ }, async () => {
365
+ const out = await closeGenesisStage({ stageKey: 'charter', taskIds: [101], closedBy: FOUNDER });
366
+ assert.equal(out.closed, false);
367
+ assert.equal(out.reason, 'stage_tasks_unfinished');
368
+ });
369
+ });
370
+
371
+ test('task 1005121: cast_via stage_close is internal, genesis-only and yes-only; the vote route never passes it', async () => {
372
+ assert.equal((await castVote({ itemId: '90', voterId: FOUNDER, direction: 'yes', castVia: 'proxy' })).reason, 'bad_cast_via');
373
+ await withDb({
374
+ getBoardItemForVote: async () => ({ ...stageItem(), subject_type: 'full_idea' }),
375
+ insertBoardVote: async () => { throw new Error('must not be reached'); },
376
+ }, async () => {
377
+ assert.equal((await castVote({ itemId: '90', voterId: FOUNDER, direction: 'yes', castVia: 'stage_close' })).reason, 'bad_cast_via');
378
+ });
379
+ const routes = fs.readFileSync(path.join(GOV, 'routes', 'government.js'), 'utf8');
380
+ const call = routes.slice(routes.indexOf('const out = await board.castVote({'), routes.indexOf('const out = await board.castVote({') + 300);
381
+ assert.doesNotMatch(call, /castVia|cast_via/, 'a request can never claim its vote was a stage-close confirm');
382
+ assert.ok(routes.includes("closeGenesisStage: require('../genesis-close').closeGenesisStage"), 'the one-call close is on the port');
383
+ const sql = fs.readFileSync(path.join(GOV, 'migrations', 'government_038_vote_cast_via.sql'), 'utf8');
384
+ assert.match(sql, /cast_via text NOT NULL DEFAULT 'ballot'/, 'every vote before the column reads as a ballot');
385
+ assert.match(sql, /cast_via IN \('ballot', 'stage_close'\) AND \(cast_via = 'ballot' OR direction = 'yes'\)/);
386
+ const dbSrc = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
387
+ assert.match(dbSrc, /INSERT INTO government_board_votes \(item_id, voter_id, direction, reason_md, section_key, cast_via\)/);
388
+ });
@@ -54,6 +54,7 @@ const NON_GRANT_MIGRATIONS = new Set([
54
54
  'government_032_ask_flags.sql', // the reword list: matters a reader flagged as unclear (owner 2026-10-23)
55
55
  'government_036_access_holders.sql', // who holds the keys outside Bongos + the access:<key> ask seat (task 1005000)
56
56
  'government_037_constitution.sql', // the constitution in force as a durable row (task 1005140)
57
+ 'government_038_vote_cast_via.sql', // HOW a vote was cast: a ballot, or a stage-close confirm (task 1005121)
57
58
  ]);
58
59
 
59
60
  // Permission keys RENAMED after the grant migrations that first granted them.
@@ -0,0 +1,178 @@
1
+ // tests/home_idea_box.mjs — the ideator's Home opens on the idea box (task 1005078,
2
+ // goal 1000095, the owner's work-first hall).
3
+ //
4
+ // Runs modules/hall-ui/public/home-idea-box.js with the city's three files in a real
5
+ // page, the reads stubbed by the hall-preview fixture: the box shows only in Ideating
6
+ // and sits above the sky; it is ONE box and a button (owner, 2026-11-05); filing is
7
+ // POST /inbox with { title, body_md: '', kind: 'feature' }; an empty line files
8
+ // nothing; a refused filing keeps the words and says why; a filed one clears and links
9
+ // to Your thinking; the box shows in Ideating even when the sky could not be read; at
10
+ // phone width nothing scrolls sideways. The pure halves run in node. Skips the browser
11
+ // cases where no system Edge or Chrome is installed.
12
+ //
13
+ // Run: node --test tests/home_idea_box.mjs
14
+ import assert from 'node:assert/strict';
15
+ import { test, after } from 'node:test';
16
+ import { createRequire } from 'node:module';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
24
+ const pub = (f) => fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', f), 'utf8');
25
+ const Box = require(path.join(ROOT, 'modules', 'hall-ui', 'public', 'home-idea-box.js'));
26
+ const CSS = pub('style.css') + pub('home-city.css') + pub('home-idea-box.css');
27
+ const CITY = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'home__city.json'), 'utf8');
28
+ const FILED = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'inbox.post.json'), 'utf8');
29
+
30
+ let browser = null;
31
+ try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
32
+ after(() => browser && browser.close());
33
+ const skip = !browser && 'no system Edge or Chrome';
34
+
35
+ const stubs = (craft, { city = true, refuse = false } = {}) => `
36
+ window.__asked = [];
37
+ window.BongosClient = { createClient: () => ({ request: async (m, p, o) => {
38
+ window.__asked.push(m + ' ' + p + (o && o.body ? ' ' + JSON.stringify(o.body) : ''));
39
+ if (p.startsWith('/api/bongos/home/city')) return ${city} ? { ok: true, status: 200, data: ${CITY} } : { ok: false, status: 500, data: null };
40
+ if (m === 'POST' && p === '/api/bongos/inbox') return ${refuse}
41
+ ? { ok: false, status: 422, data: { error: { code: 'idea_rejected', details: { reason: 'the title is a duplicate' } } } }
42
+ : { ok: true, status: 201, data: ${FILED} };
43
+ return { ok: false, status: 404, data: null };
44
+ } }) };
45
+ window.OTBCraftMode = { iconHtml: () => '', choose: () => {}, current: () => document.documentElement.dataset.craft || null };
46
+ window.OTB = { contributeHallWidget(w) { window.__widget = w; } };`;
47
+
48
+ async function boot({ craft = 'ideating', width = 1440, ...opts } = {}) {
49
+ const ctx = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: 'reduce' });
50
+ const page = await ctx.newPage();
51
+ await page.setContent(`<!doctype html><html data-theme="dark"${craft ? ` data-craft="${craft}"` : ''}><head><style>${CSS}</style></head><body><section id="welcome-scroll" class="scroll">setup guide</section><section id="home-city" class="scroll home-city" hidden></section></body></html>`);
52
+ await page.addScriptTag({ content: stubs(craft, opts) });
53
+ for (const f of ['home-idea-box.js', 'city-draw.js', 'home-city-draw.js', 'home-city.js']) await page.addScriptTag({ content: pub(f) });
54
+ await page.evaluate(() => window.__widget.mount(document.getElementById('home-city')));
55
+ await page.waitForFunction(() => window.__asked.some((a) => a.includes('/home/city')));
56
+ return { ctx, page };
57
+ }
58
+
59
+ test('the pure halves: the first line is the title and the rest the details; a refusal says why', () => {
60
+ assert.deepEqual(Box.body(' a new idea '), { title: 'a new idea', body_md: '', kind: 'feature' });
61
+ assert.deepEqual(Box.body('\n the title\r\nline two\n\nline three\n'), { title: 'the title', body_md: 'line two\n\nline three', kind: 'feature' });
62
+ assert.deepEqual(Box.body(' \n '), { title: '', body_md: '', kind: 'feature' });
63
+ assert.match(Box.explain({ status: 422, data: { error: { details: { reason: 'too short' } } } }), /^Not filed: too short\. Your idea is still here\.$/);
64
+ assert.match(Box.explain({ status: 401, data: null }), /session may have expired/);
65
+ assert.match(Box.explain(null), /Nothing was saved/);
66
+ });
67
+
68
+ test('in Ideating the box is the first thing in the section, ONE box and a button, and the setup guide is hidden', { skip }, async () => {
69
+ const { ctx, page } = await boot();
70
+ await page.waitForFunction(() => document.querySelector('.hc-svg').childElementCount > 0);
71
+ const s = await page.evaluate(() => {
72
+ const host = document.getElementById('home-city'), box = host.querySelector('.hib');
73
+ return {
74
+ first: host.firstElementChild === box, shown: !box.hidden && !host.hidden,
75
+ fields: box.querySelectorAll('input, textarea, select').length,
76
+ keys: document.getElementById(box.querySelector('.hib-title').getAttribute('aria-describedby')).textContent,
77
+ buttons: [...box.querySelectorAll('button')].map((b) => b.textContent),
78
+ label: box.querySelector('label[for="hib-title"]').textContent,
79
+ boxAboveSky: box.getBoundingClientRect().bottom <= host.querySelector('.hc-svg').getBoundingClientRect().top,
80
+ guide: getComputedStyle(document.getElementById('welcome-scroll')).display,
81
+ };
82
+ });
83
+ await ctx.close();
84
+ assert.equal(s.first, true);
85
+ assert.equal(s.shown, true);
86
+ assert.equal(s.fields, 1, 'one box (owner, 2026-11-05)');
87
+ assert.equal(s.keys, 'Enter files the idea. Shift and Enter starts a new line.');
88
+ assert.deepEqual(s.buttons, ['File idea']);
89
+ assert.equal(s.label, 'What is your idea?');
90
+ assert.equal(s.boxAboveSky, true);
91
+ assert.equal(s.guide, 'none');
92
+ });
93
+
94
+ test('in any other craft, or none, there is no box and the setup guide shows', { skip }, async () => {
95
+ for (const craft of ['building', null]) {
96
+ const { ctx, page } = await boot({ craft });
97
+ const s = await page.evaluate(() => ({ hidden: document.querySelector('.hib').hidden, guide: getComputedStyle(document.getElementById('welcome-scroll')).display }));
98
+ await ctx.close();
99
+ assert.equal(s.hidden, true, String(craft));
100
+ assert.equal(s.guide, 'block', String(craft));
101
+ }
102
+ });
103
+
104
+ test('filing posts the line as a feature idea, clears the box and links to Your thinking; an empty line files nothing', { skip }, async () => {
105
+ const { ctx, page } = await boot();
106
+ await page.click('.hib-file');
107
+ const empty = await page.evaluate(() => ({ said: document.querySelector('.hib-said').textContent, posts: window.__asked.filter((a) => a.startsWith('POST')).length }));
108
+ await page.fill('.hib-title', ' A place to type an idea ');
109
+ await page.press('.hib-title', 'Enter');
110
+ await page.waitForFunction(() => document.querySelector('.hib-said a'));
111
+ const s = await page.evaluate(() => ({
112
+ posts: window.__asked.filter((a) => a.startsWith('POST')),
113
+ value: document.querySelector('.hib-title').value,
114
+ said: document.querySelector('.hib-said').textContent,
115
+ href: document.querySelector('.hib-said a').getAttribute('href'),
116
+ err: document.querySelector('.hib-said').classList.contains('hib-said--err'),
117
+ }));
118
+ await ctx.close();
119
+ assert.equal(empty.posts, 0);
120
+ assert.equal(empty.said, 'Type your idea first.');
121
+ assert.deepEqual(s.posts, ['POST /api/bongos/inbox {"title":"A place to type an idea","body_md":"","kind":"feature"}']);
122
+ assert.equal(s.value, '');
123
+ assert.match(s.said, /^Filed\. It waits for the next triage\. See it in Your thinking$/);
124
+ assert.equal(s.href, '/thinking');
125
+ assert.equal(s.err, false);
126
+ });
127
+
128
+ test('Shift+Enter is a new line and the box grows; Enter then files the first line as the title, the rest as details', { skip }, async () => {
129
+ const { ctx, page } = await boot();
130
+ await page.click('.hib-title');
131
+ const h0 = await page.evaluate(() => document.querySelector('.hib-title').getBoundingClientRect().height);
132
+ await page.keyboard.type('The title');
133
+ await page.keyboard.press('Shift+Enter');
134
+ await page.keyboard.type('more about it');
135
+ await page.keyboard.press('Shift+Enter');
136
+ await page.keyboard.type('and more');
137
+ const before = await page.evaluate(() => ({ value: document.querySelector('.hib-title').value, h: document.querySelector('.hib-title').getBoundingClientRect().height, posts: window.__asked.filter((a) => a.startsWith('POST')).length }));
138
+ await page.keyboard.press('Enter');
139
+ await page.waitForFunction(() => document.querySelector('.hib-said a'));
140
+ const after = await page.evaluate(() => ({ posts: window.__asked.filter((a) => a.startsWith('POST')), h: document.querySelector('.hib-title').getBoundingClientRect().height }));
141
+ await ctx.close();
142
+ assert.equal(before.value, 'The title\nmore about it\nand more');
143
+ assert.equal(before.posts, 0, 'Shift+Enter never files');
144
+ assert.ok(before.h > h0, `the box grew (${h0} -> ${before.h})`);
145
+ assert.deepEqual(after.posts, ['POST /api/bongos/inbox {"title":"The title","body_md":"more about it\\nand more","kind":"feature"}']);
146
+ assert.equal(after.h, h0, 'and shrinks back once filed');
147
+ });
148
+
149
+ test('a refused filing keeps the words and says why', { skip }, async () => {
150
+ const { ctx, page } = await boot({ refuse: true });
151
+ await page.fill('.hib-title', 'A duplicate');
152
+ await page.click('.hib-file');
153
+ await page.waitForFunction(() => document.querySelector('.hib-said').textContent);
154
+ const s = await page.evaluate(() => ({ value: document.querySelector('.hib-title').value, said: document.querySelector('.hib-said').textContent, err: document.querySelector('.hib-said').classList.contains('hib-said--err'), link: !!document.querySelector('.hib-said a') }));
155
+ await ctx.close();
156
+ assert.equal(s.value, 'A duplicate');
157
+ assert.equal(s.said, 'Not filed: the title is a duplicate. Your idea is still here.');
158
+ assert.equal(s.err, true);
159
+ assert.equal(s.link, false);
160
+ });
161
+
162
+ test('the box shows in Ideating even when the sky could not be read', { skip }, async () => {
163
+ const { ctx, page } = await boot({ city: false });
164
+ const s = await page.evaluate(() => ({ host: document.getElementById('home-city').hidden, box: document.querySelector('.hib').hidden }));
165
+ await ctx.close();
166
+ assert.deepEqual(s, { host: false, box: false });
167
+ });
168
+
169
+ test('at phone width the box stacks, every control clears 24px, and nothing scrolls sideways', { skip }, async () => {
170
+ const { ctx, page } = await boot({ width: 320 });
171
+ const s = await page.evaluate(() => ({
172
+ over: document.documentElement.scrollWidth - document.documentElement.clientWidth,
173
+ sizes: [...document.querySelectorAll('.hib-title, .hib-file')].map((e) => { const r = e.getBoundingClientRect(); return [Math.round(r.width), Math.round(r.height)]; }),
174
+ }));
175
+ await ctx.close();
176
+ assert.equal(s.over, 0);
177
+ for (const [w, h] of s.sizes) assert.ok(w >= 24 && h >= 24, `${w}x${h}`);
178
+ });