@bongos/core 1.21.173 → 1.21.175

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 (38) hide show
  1. package/.bongos-core.json +65 -40
  2. package/docs/copy-inventory.md +56 -62
  3. package/docs/copy-registry.json +91 -156
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +5 -0
  6. package/docs/page-readings.json +64 -61
  7. package/modules/copy-desk/routes/copy-desk.js +4 -4
  8. package/modules/hall-ui/public/blockers.html +2 -0
  9. package/modules/hall-ui/public/blockers.js +13 -0
  10. package/modules/hall-ui/public/board-room.css +5 -0
  11. package/modules/hall-ui/public/board-room.html +1 -0
  12. package/modules/hall-ui/public/board-room.js +2 -2
  13. package/modules/hall-ui/public/city-decide.js +36 -2
  14. package/modules/hall-ui/public/city.css +8 -2
  15. package/modules/hall-ui/public/city.html +5 -5
  16. package/modules/hall-ui/public/goal-inbox.js +69 -86
  17. package/modules/hall-ui/public/hall-ask.css +57 -0
  18. package/modules/hall-ui/public/hall-ask.js +50 -0
  19. package/modules/hall-ui/public/index.html +3 -0
  20. package/modules/hall-ui/records/hall-ask-card.md +24 -0
  21. package/modules/lifecycle/artist-review-goal.js +103 -0
  22. package/modules/lifecycle/db-tasks.js +17 -1
  23. package/modules/lifecycle/db-versions.js +5 -1
  24. package/modules/lifecycle/page-tweak-claim.js +3 -3
  25. package/modules/lifecycle/page-tweak-write.js +12 -4
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +16 -0
  29. package/src/module-api.js +1 -1
  30. package/tests/artist_review_goal.mjs +119 -0
  31. package/tests/blockers_hall.mjs +3 -1
  32. package/tests/copy_desk_page_claim.mjs +6 -1
  33. package/tests/copy_desk_page_submit.mjs +1 -1
  34. package/tests/fixtures/page-ledger.mjs +13 -0
  35. package/tests/hall_city_rewording.mjs +11 -0
  36. package/tests/home_attention_cards.mjs +2 -0
  37. package/tests/rot_inbox.mjs +10 -7
  38. package/tests/task_goal_required.mjs +12 -2
@@ -46,37 +46,36 @@
46
46
 
47
47
  // The goal-detail hash route on the Goals page (matches goals.js's row link).
48
48
  const goalHref = (goalId) => `/goals#/goal/${encodeURIComponent(goalId)}`;
49
- const noteHtml = (note) => (note ? `<p class="ginbox__note">“${escapeHtml(note)}”</p>` : '');
50
49
 
51
50
  function invitationRow(inv) {
52
51
  const who = inv.invited_by_name || inv.invited_by_login || 'someone';
53
- return `<li class="ginbox__item" data-goal="${escapeHtml(String(inv.goal_id))}">
54
- <div class="ginbox__body">
55
- <a class="ginbox__goal" href="${goalHref(inv.goal_id)}" title="Open ${escapeHtml(inv.goal_title || 'goal')}">${escapeHtml(inv.goal_title || 'a goal')}</a>
56
- <span class="ginbox__sub">invited by ${escapeHtml(who)}</span>
57
- ${noteHtml(inv.note)}
58
- </div>
59
- <div class="ginbox__actions">
60
- <button class="btn-ghost" type="button" data-accept-inv="${escapeHtml(String(inv.id))}">Accept</button>
61
- <button class="btn-ghost" type="button" data-decline-inv="${escapeHtml(String(inv.id))}">Decline</button>
62
- </div>
63
- </li>`;
52
+ const id = String(inv.id);
53
+ return window.OTBAsk.html({
54
+ cls: 'ginbox__item', attrs: { 'data-goal': String(inv.goal_id) },
55
+ where: `Invited by ${who}`,
56
+ q: `Join ${inv.goal_title || 'a goal'}?`, qHref: goalHref(inv.goal_id),
57
+ said: inv.note ? `“${inv.note}”` : '', row: true,
58
+ opts: [
59
+ { label: 'Join', result: 'You can claim its work', rec: true, attrs: { 'data-accept-inv': id } },
60
+ { label: 'No thanks', result: 'The invitation closes', attrs: { 'data-decline-inv': id } },
61
+ ],
62
+ });
64
63
  }
65
64
 
66
65
  function requestRow(r) {
67
66
  const who = r.display_name || r.github_login || 'a builder';
68
- const rank = r.rank ? ` · ${escapeHtml(rankLabel(r.rank, { empty: '' }))}` : '';
69
- return `<li class="ginbox__item" data-goal="${escapeHtml(String(r.goal_id))}">
70
- <div class="ginbox__body">
71
- <a class="ginbox__goal" href="${goalHref(r.goal_id)}" title="Open ${escapeHtml(r.goal_title || 'goal')}">${escapeHtml(r.goal_title || 'a goal')}</a>
72
- <span class="ginbox__sub">${escapeHtml(who)}${rank} asks to join</span>
73
- ${noteHtml(r.note)}
74
- </div>
75
- <div class="ginbox__actions">
76
- <button class="btn-ghost" type="button" data-approve-req="${escapeHtml(String(r.id))}">Approve</button>
77
- <button class="btn-ghost" type="button" data-deny-req="${escapeHtml(String(r.id))}">Deny</button>
78
- </div>
79
- </li>`;
67
+ const rank = r.rank ? ` · ${rankLabel(r.rank, { empty: '' })}` : '';
68
+ const id = String(r.id);
69
+ return window.OTBAsk.html({
70
+ cls: 'ginbox__item', attrs: { 'data-goal': String(r.goal_id) },
71
+ where: `${r.goal_title || 'A goal'}${rank}`,
72
+ q: `Let ${who} join?`, qHref: goalHref(r.goal_id),
73
+ said: r.note ? `“${r.note}”` : '', row: true,
74
+ opts: [
75
+ { label: 'Let them in', result: 'They can claim its work', rec: true, attrs: { 'data-approve-req': id } },
76
+ { label: 'Not now', result: 'The request closes', attrs: { 'data-deny-req': id } },
77
+ ],
78
+ });
80
79
  }
81
80
 
82
81
  function pendingLine(myPending) {
@@ -289,20 +288,16 @@
289
288
  // non-triager's fetch 403s and the slice renders nothing. One gate, on the
290
289
  // server, beats a client check that can disagree with it.
291
290
  function nodRowHtml(r) {
292
- const title = escapeHtml(r.title || `task #${r.task_id}`);
293
- const kind = r.kind ? `<span class="ginbox__sub">${escapeHtml(r.kind)}</span>` : '';
294
- const who = r.filed_by_login ? ` · filed by ${escapeHtml(r.filed_by_login)}` : '';
295
- const goal = r.goal_title ? escapeHtml(r.goal_title) : `goal ${escapeHtml(String(r.goal_id))}`;
296
- return `<li class="ginbox__item" data-goal="${escapeHtml(String(r.goal_id))}">
297
- <div class="ginbox__body">
298
- <a class="ginbox__goal" href="/task/${encodeURIComponent(r.task_id)}">${title}</a>
299
- <span class="ginbox__sub">${goal}${who}</span>
300
- </div>
301
- ${kind}
302
- <div class="ginbox__actions">
303
- <button class="btn-ghost" type="button" data-nod-task="${escapeHtml(String(r.task_id))}">Give the nod</button>
304
- </div>
305
- </li>`;
291
+ const goal = r.goal_title || `goal ${r.goal_id}`;
292
+ const who = r.filed_by_login ? ` · filed by ${r.filed_by_login}` : '';
293
+ return window.OTBAsk.html({
294
+ cls: 'ginbox__item', attrs: { 'data-goal': String(r.goal_id) },
295
+ where: `${goal}${who}`,
296
+ q: r.title || `Task ${r.task_id}`, qHref: `/task/${encodeURIComponent(r.task_id)}`,
297
+ opts: [
298
+ { label: 'Give the nod', result: 'It opens for anyone to claim', rec: true, attrs: { 'data-nod-task': String(r.task_id) } },
299
+ ],
300
+ });
306
301
  }
307
302
 
308
303
  function renderNodQueue(data) {
@@ -366,23 +361,18 @@
366
361
  // One word each brings it back into that band; the sentence each answer means
367
362
  // rides the `title`, and the head already says what the question is.
368
363
  function removalRowHtml(r) {
369
- const title = escapeHtml(r.title || `task #${r.proposal_task_id}`);
370
- const from = r.ship_task_id
371
- ? `deleted by task #${escapeHtml(String(r.ship_task_id))}`
372
- : 'from a ship whose id could not be read';
373
- const goal = r.goal_title ? ` · ${escapeHtml(r.goal_title)}` : '';
374
- return `<li class="ginbox__item ginbox__item--stacked">
375
- <div class="ginbox__body">
376
- <a class="ginbox__goal" href="/task/${encodeURIComponent(r.proposal_task_id)}">${title}</a>
377
- <span class="ginbox__sub">${from}${goal} — open it to see what went</span>
378
- </div>
379
- <div class="ginbox__actions">
380
- <button class="btn-ghost" type="button" data-removal-task="${escapeHtml(String(r.proposal_task_id))}"
381
- data-removal-verdict="confirmed" title="That work is genuinely gone">Confirm</button>
382
- <button class="btn-ghost" type="button" data-removal-task="${escapeHtml(String(r.proposal_task_id))}"
383
- data-removal-verdict="dismissed" title="A refactor, a move, or work already superseded">Dismiss</button>
384
- </div>
385
- </li>`;
364
+ const from = r.ship_task_id ? `Deleted by task ${r.ship_task_id}` : 'Deleted by a ship';
365
+ const id = String(r.proposal_task_id);
366
+ return window.OTBAsk.html({
367
+ cls: 'ginbox__item',
368
+ where: `${from}${r.goal_title ? ' · ' + r.goal_title : ''}`,
369
+ q: r.title || `Task ${r.proposal_task_id}`, qHref: `/task/${encodeURIComponent(r.proposal_task_id)}`,
370
+ row: true,
371
+ opts: [
372
+ { label: 'It is gone', result: 'That work is really removed', attrs: { 'data-removal-task': id, 'data-removal-verdict': 'confirmed' } },
373
+ { label: 'Not removed', result: 'It moved or was replaced', attrs: { 'data-removal-task': id, 'data-removal-verdict': 'dismissed' } },
374
+ ],
375
+ });
386
376
  }
387
377
 
388
378
  function renderRemovals(data) {
@@ -437,29 +427,23 @@
437
427
  }
438
428
 
439
429
  function rotRowHtml(r) {
440
- const href = r.tier === 'goal'
441
- ? goalHref(r.id)
442
- : `/task/${encodeURIComponent(r.id)}`;
443
- const where = r.tier === 'goal'
444
- ? 'goal'
445
- : (r.goal_title ? escapeHtml(r.goal_title) : `goal ${escapeHtml(String(r.goal_id || '—'))}`);
446
- // `watered` is worth showing: a row that is back DESPITE having been watered is
447
- // a different message from one nobody has ever looked at, and hiding that would
448
- // make the second watering feel like the first one did nothing.
430
+ const href = r.tier === 'goal' ? goalHref(r.id) : `/task/${encodeURIComponent(r.id)}`;
431
+ const where = r.tier === 'goal' ? 'Goal' : (r.goal_title || `goal ${r.goal_id || '—'}`);
432
+ // a row back DESPITE a watering says something a first-timer does not
449
433
  const again = r.ever_watered ? ' · watered before' : '';
450
- const buttons = (Array.isArray(r.verbs) ? r.verbs : []).map((v) => {
451
- const path = String(v.path || '').replace(':id', encodeURIComponent(r.id));
452
- return `<button class="btn-ghost" type="button" data-rot-path="${escapeHtml(path)}"`
453
- + ` data-rot-verb="${escapeHtml(String(v.verb || ''))}"`
454
- + ` title="${escapeHtml(String(v.hint || ''))}">${escapeHtml(String(v.label || v.verb || '?'))}</button>`;
455
- }).join('');
456
- return `<li class="ginbox__item" data-rot-tier="${escapeHtml(String(r.tier))}">
457
- <div class="ginbox__body">
458
- <a class="ginbox__goal" href="${escapeHtml(href)}">${escapeHtml(r.title || `${r.tier} #${r.id}`)}</a>
459
- <span class="ginbox__sub">${escapeHtml(String(r.status || ''))} · ${where} · ${escapeHtml(untouchedFor(r.last_touched_at))}${again}</span>
460
- </div>
461
- <div class="ginbox__actions">${buttons}</div>
462
- </li>`;
434
+ const opts = (Array.isArray(r.verbs) ? r.verbs : []).map((v) => ({
435
+ label: String(v.label || v.verb || '?'),
436
+ result: String(v.hint || ''),
437
+ attrs: {
438
+ 'data-rot-path': String(v.path || '').replace(':id', encodeURIComponent(r.id)),
439
+ 'data-rot-verb': String(v.verb || ''),
440
+ },
441
+ }));
442
+ return window.OTBAsk.html({
443
+ cls: 'ginbox__item', attrs: { 'data-rot-tier': String(r.tier) },
444
+ where: `${where} · ${untouchedFor(r.last_touched_at)}${again}`,
445
+ q: r.title || `${r.tier} ${r.id}`, qHref: href, opts,
446
+ });
463
447
  }
464
448
 
465
449
  // What each verb says when it lands. Keyed by the SERVER's verb name so a new verb
@@ -531,20 +515,19 @@
531
515
  titleClass: 'ginbox__goal',
532
516
  noteClass: 'ginbox__note',
533
517
  whenClass: 'ginbox__sub',
534
- // The SAME overlay task-detail.js has rendered since task 1003066's
535
- // predecessor — this slice is what gives it its first door in.
536
518
  esc: window.OTBKit.esc,
537
519
  fmtDate: window.OTBKit.fmtAbsDate,
538
520
  blockerHref: (id) => `#/blocker/${encodeURIComponent(id)}`,
539
521
  taskHref: (id) => (window.OTBTaskDetail ? window.OTBTaskDetail.href(id) : `/task/${encodeURIComponent(id)}`),
540
522
  });
541
- return `<li class="ginbox__item">
542
- <div class="ginbox__body">
543
- ${facts.titleHtml}
544
- ${facts.factsHtml}
545
- ${facts.noteHtml}
546
- </div>
547
- </li>`;
523
+ // Was a link only; now it answers in place: the overlay is where it resolves.
524
+ return window.OTBAsk.html({
525
+ cls: 'ginbox__item',
526
+ where: 'Waiting on the project owner',
527
+ q: b.title || `Blocker ${b.id}`, qHref: `#/blocker/${encodeURIComponent(b.id)}`,
528
+ saidHtml: facts.factsHtml + facts.noteHtml,
529
+ opts: [{ label: 'Resolve it', result: 'Say what settled it', rec: true, href: `#/blocker/${encodeURIComponent(b.id)}` }],
530
+ });
548
531
  }
549
532
 
550
533
  function renderBlockers(data) {
@@ -0,0 +1,57 @@
1
+ /* modules/hall-ui/public/hall-ask.css — THE CITY'S ASK CARD, IN THE HALL
2
+ (task 1004820, criterion wa6-hall-reads-like-home).
3
+ The hall's actions are drawn as the city's ask card (street-deck.css `.ask`):
4
+ where it sits, the question large and light, what was said, then each answer
5
+ as a one-tap option chip carrying its result line. The hall keeps its own
6
+ prefix (`hask`) so a page that loads both sheets never cascades one into the
7
+ other; the tokens are the street's glass chip recipe on the hall's own
8
+ ground, light first and then twice for dark (the hall's switch). */
9
+
10
+ body {
11
+ --hask-chip: color-mix(in srgb, var(--ink) 4%, transparent);
12
+ --hask-chip-pick: color-mix(in srgb, var(--bg-card) 80%, var(--bg));
13
+ --hask-edge: color-mix(in srgb, var(--ink) 9%, transparent);
14
+ --hask-edge-pick: color-mix(in srgb, var(--ink) 34%, transparent);
15
+ }
16
+ html[data-theme="dark"] body {
17
+ --hask-chip: color-mix(in srgb, var(--ink) 5%, transparent);
18
+ --hask-chip-pick: color-mix(in srgb, var(--ink) 11%, transparent);
19
+ --hask-edge: color-mix(in srgb, var(--ink) 11%, transparent);
20
+ --hask-edge-pick: color-mix(in srgb, var(--ink) 42%, transparent);
21
+ }
22
+ @media (prefers-color-scheme: dark) {
23
+ html:not([data-theme]) body {
24
+ --hask-chip: color-mix(in srgb, var(--ink) 5%, transparent);
25
+ --hask-chip-pick: color-mix(in srgb, var(--ink) 11%, transparent);
26
+ --hask-edge: color-mix(in srgb, var(--ink) 11%, transparent);
27
+ --hask-edge-pick: color-mix(in srgb, var(--ink) 42%, transparent);
28
+ }
29
+ }
30
+
31
+ /* the card: one item that waits on the reader */
32
+ .hask { list-style: none; display: flex; flex-direction: column; gap: 12px; padding: 16px 16px 14px; margin: 0 0 10px; box-sizing: border-box; border-radius: 22px; background: var(--hask-chip); border: 1px solid var(--hask-edge); color: var(--ink); }
33
+ .hask:last-child { margin-bottom: 0; }
34
+ .hask__where { display: flex; align-items: center; gap: 8px; min-height: 24px; font-size: 13px; color: var(--ink-soft); min-width: 0; }
35
+ .hask__dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
36
+ .hask__where-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
37
+ .hask__q { margin: 0; font-size: 21px; font-weight: 300; letter-spacing: -0.02em; line-height: 1.2; overflow-wrap: anywhere; }
38
+ .hask__q a { color: inherit; text-decoration: none; }
39
+ .hask__q a:hover { text-decoration: underline; text-underline-offset: 4px; text-decoration-color: var(--hask-edge-pick); }
40
+ .hask__said { margin: -4px 0 0; font-size: 14px; line-height: 1.45; color: var(--ink-soft); }
41
+ .hask__opts { display: flex; flex-direction: column; gap: 8px; }
42
+ .hask__opt { display: grid; grid-template-columns: minmax(0, 1fr); row-gap: 2px; align-items: center; min-height: 52px; width: 100%; padding: 9px 16px; box-sizing: border-box; border-radius: 15px; border: 1px solid var(--hask-edge); background: transparent; color: var(--ink); font-family: var(--font-body); text-align: left; text-decoration: none; cursor: pointer; }
43
+ .hask__opt:hover { background: var(--hask-chip-pick); }
44
+ .hask__opt--rec { background: var(--hask-chip-pick); border-color: var(--hask-edge-pick); }
45
+ .hask__opt[disabled] { cursor: progress; opacity: .6; }
46
+ .hask__opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
47
+ .hask__label { font-size: 15px; font-weight: 500; min-width: 0; }
48
+ .hask__result { font-size: 13px; line-height: 1.35; color: var(--ink-soft); min-width: 0; }
49
+ @media (min-width: 761px) {
50
+ .hask__opts--row { flex-direction: row; }
51
+ .hask__opts--row .hask__opt { flex: 1 1 0; }
52
+ }
53
+
54
+ /* an inbox row drawn as the card keeps .ginbox__item (the count and the
55
+ card's visibility read it); the card's own box wins over the ledger row */
56
+ .ginbox > .hask.ginbox__item { display: flex; flex-direction: column; align-items: stretch; gap: 12px; padding: 16px 16px 14px; border: 1px solid var(--hask-edge); margin: 0 0 10px; }
57
+ .ginbox > .hask.ginbox__item:first-child { border-top: 1px solid var(--hask-edge); }
@@ -0,0 +1,50 @@
1
+ // modules/hall-ui/public/hall-ask.js — THE CITY'S ASK CARD, IN THE HALL
2
+ // (task 1004820, criterion wa6-hall-reads-like-home).
3
+ //
4
+ // One builder for every hall action that waits on the reader: where it sits,
5
+ // the question, what was said, and each answer as a one-tap option chip with
6
+ // its result line (the city's ask card, street-deck.js, in hall-ask.css).
7
+ //
8
+ // It draws markup only. An option carries the SAME data-* attributes the
9
+ // page's delegated click handler already reads, so no action gains a second
10
+ // path: switching a row to the card changes how it looks, never what it calls.
11
+ // An option with `href` is a link (an act that needs its own panel).
12
+ //
13
+ // OTBAsk.html({ where, q, qHref, said, saidHtml, tag, cls, attrs,
14
+ // row: bool, opts: [{ label, result, rec, href, attrs }] })
15
+ //
16
+ // Every string is escaped here; `saidHtml` is the one pre-built slot, for a
17
+ // caller whose facts are already escaped markup (blockerFacts).
18
+ (function () {
19
+ const esc = (s) => String(s == null ? '' : s)
20
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
21
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
22
+ const attrsOf = (a) => Object.keys(a || {})
23
+ .filter((k) => a[k] != null && a[k] !== false)
24
+ .map((k) => ` ${k}="${esc(a[k])}"`).join('');
25
+
26
+ function optHtml(o) {
27
+ const cls = 'hask__opt' + (o.rec ? ' hask__opt--rec' : '');
28
+ const inner = `<span class="hask__label">${esc(o.label)}</span>`
29
+ + (o.result ? `<span class="hask__result">${esc(o.result)}</span>` : '');
30
+ return o.href
31
+ ? `<a class="${cls}" href="${esc(o.href)}"${attrsOf(o.attrs)}>${inner}</a>`
32
+ : `<button class="${cls}" type="button"${attrsOf(o.attrs)}>${inner}</button>`;
33
+ }
34
+
35
+ function html(c) {
36
+ const tag = c.tag || 'li';
37
+ const opts = (c.opts || []).filter(Boolean);
38
+ const q = c.qHref ? `<a href="${esc(c.qHref)}">${esc(c.q)}</a>` : esc(c.q);
39
+ return `<${tag} class="hask${c.cls ? ' ' + esc(c.cls) : ''}"${attrsOf(c.attrs)}>`
40
+ + (c.where ? `<div class="hask__where"><span class="hask__dot" aria-hidden="true"></span><span class="hask__where-t">${esc(c.where)}</span></div>` : '')
41
+ + `<h4 class="hask__q">${q}</h4>`
42
+ + (c.saidHtml ? `<div class="hask__said">${c.saidHtml}</div>` : (c.said ? `<p class="hask__said">${esc(c.said)}</p>` : ''))
43
+ + (opts.length ? `<div class="hask__opts${c.row ? ' hask__opts--row' : ''}">${opts.map(optHtml).join('')}</div>` : '')
44
+ + `</${tag}>`;
45
+ }
46
+
47
+ const api = { html, esc };
48
+ if (typeof window !== 'undefined') window.OTBAsk = api;
49
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
50
+ })();
@@ -19,6 +19,7 @@
19
19
  <link rel="preconnect" href="https://fonts.googleapis.com">
20
20
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
21
21
  <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
22
+ <link rel="stylesheet" href="/builders/hall-ask.css">
22
23
  </head>
23
24
  <body data-page="home">
24
25
 
@@ -136,6 +137,8 @@
136
137
  <!-- task 1003455: the shared blocker-row renderer (window.OTBBlockersLib), read by
137
138
  both goal-inbox.js's attention-inbox slice below and the /blockers page.
138
139
  Loaded BEFORE goal-inbox.js, which calls it while rendering. -->
140
+ <!-- task 1004820: the city ask card, drawn for every inbox action -->
141
+ <script src="/builders/hall-ask.js" defer></script>
139
142
  <script src="/builders/blockers-lib.js" defer></script>
140
143
  <!-- ADR 0112 (task 1954): the home-page goal-membership inbox — invitations to
141
144
  accept + join-requests to approve. Self-registers; hidden when empty. -->
@@ -0,0 +1,24 @@
1
+ # The city's ask card, in the hall — every action a builder answers in place (task 1004820)
2
+
3
+ Criterion `wa6-hall-reads-like-home` of goal 1000095 (owner ask 2026-10-23): the buttons a builder acts
4
+ on inside the hall use the card template the city view built (`street-deck.css` `.ask`, task 1004702).
5
+
6
+ - **One builder, one sheet.** `hall-ask.js` (`window.OTBAsk.html`) draws the card — where it sits, the
7
+ question, what was said, each answer as a one-tap option chip with its result line — and
8
+ `hall-ask.css` styles it under its own `hask` prefix on the hall's tokens, light and twice dark. The
9
+ city keeps `.ask`; a page loading both never cascades one into the other.
10
+ - **No second action path.** An option carries the same `data-*` the page's delegated handler already
11
+ read, so switching a row to the card changes how it looks, never what it calls. An option with `href`
12
+ is a link, for an act that needs its own panel (resolving a blocker opens its overlay).
13
+ - **Where it is applied.** Home's attention inbox (join invitations, join requests, the nod, "was this
14
+ removed?", untouched work, and the blocker slice — which had NO action and now has "Resolve it"),
15
+ the Blockers page's closed reviewer rows (Resolve it / Link a task; the open forms are unchanged), and
16
+ the Board Room's vote (Vote yes / Object as the card's chips).
17
+ - **Rows keep `.ginbox__item`**, because `OTBAttention.refresh()` counts them; `hall-ask.css` makes the
18
+ card's own box win over the ledger row.
19
+ - **Wording follows the Home rebuild:** a question, not a label ("Join X?", "Let Ana join?"), and every
20
+ answer says what it does.
21
+
22
+ Left for the page tasks of the same criterion (1004821–1004829), which each re-lay a whole page: the
23
+ task page's lifecycle buttons, Goals' row claim/release, collab, gate, profile connections and the
24
+ government forms.
@@ -0,0 +1,103 @@
1
+ // modules/lifecycle/artist-review-goal.js — every version's ARTIST REVIEW goal
2
+ // (task 1004819). The home for copywriting and Artist Studio work: page tweak
3
+ // rounds, the engineer tasks a round leaves behind, and copy proposals.
4
+ //
5
+ // Owner direction (2026-10-23): all of that work sits under one ongoing goal, and
6
+ // every version carries one, with two standing done-when criteria (below). Before
7
+ // this the studio's create paths passed allowCatchAll, and a version with no
8
+ // "<version> — general" goal (BONGOS-V2) filed every round with no goal at all.
9
+ //
10
+ // ensureMaintenanceGoal's shape (db-goals.js), with two differences worth knowing:
11
+ // - it is matched by TITLE, not a flag. Nothing decides anything from this goal
12
+ // being the artist one except where new studio work lands, so a column (and a
13
+ // migration) would buy nothing a hand-titled goal could abuse;
14
+ // - it is NOT exempt from the version-close count. Its criteria are meant to
15
+ // hold the version open until every page has been reviewed.
16
+ // A 'planning' version takes one too, so createVersion can give a new version
17
+ // its goal at birth; a closed version never does.
18
+ //
19
+ // Runs on the caller's client (deps.client) when given, so the goal, its criteria
20
+ // and the task that needed them commit together or not at all; otherwise in a
21
+ // transaction of its own.
22
+
23
+ const api = require('../../src/module-api');
24
+
25
+ const { pool, withTx } = api;
26
+
27
+ const OPEN_TO_WORK = new Set(['planning', 'building']);
28
+
29
+ const ARTIST_REVIEW_CRITERIA = Object.freeze([
30
+ Object.freeze({
31
+ criterion_id: 'artist-every-page-reviewed',
32
+ criterion_md: 'Every page in the page inventory has had at least one artist review (a shipped tweak round) of its words before the version closes.',
33
+ }),
34
+ Object.freeze({
35
+ criterion_id: 'artist-pages-simple-to-use',
36
+ criterion_md: 'Every page is simple for a user to read and use: plain words, one clear next step, nothing a newcomer has to decode. An artist signs this off per page.',
37
+ }),
38
+ ]);
39
+
40
+ function artistReviewGoalTitle(versionId) {
41
+ return `${versionId} — artist review`;
42
+ }
43
+
44
+ async function ensureArtistReviewGoal(versionId, deps = {}) {
45
+ if (versionId == null) return null;
46
+ if (deps.client) return findOrCreate(versionId, deps.client);
47
+ // No caller transaction (a copy proposal): open one, or the advisory lock below
48
+ // would release the moment it is taken and two writers could both insert.
49
+ const txPool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
50
+ return withTx((client) => findOrCreate(versionId, client), { pool: txPool });
51
+ }
52
+
53
+ async function findOrCreate(versionId, exec) {
54
+ const title = artistReviewGoalTitle(versionId);
55
+
56
+ const { rows: vRows } = await exec.query(`SELECT status FROM versions WHERE id = $1`, [versionId]);
57
+ if (!vRows[0] || !OPEN_TO_WORK.has(vRows[0].status)) return null;
58
+
59
+ // No unique constraint on (version_id, title), so two studio writes in the same
60
+ // second would both insert without this. Released at COMMIT/ROLLBACK.
61
+ await exec.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [title]);
62
+
63
+ const { rows: found } = await exec.query(
64
+ `SELECT id, version_id, title FROM goals WHERE version_id = $1 AND title = $2 ORDER BY id LIMIT 1`,
65
+ [versionId, title]
66
+ );
67
+ if (found[0]) {
68
+ // A goal of this title made by hand (POST /goals) before the first studio
69
+ // write still gets the two standing criteria; ones already there are kept.
70
+ await seedCriteria(exec, versionId, found[0].id);
71
+ return found[0];
72
+ }
73
+
74
+ const { rows: made } = await exec.query(
75
+ `INSERT INTO goals (version_id, title, description, scope_modules, status, created_by, sort_order)
76
+ SELECT $1, $2, $3, $4, 'open', NULL, COALESCE(MAX(sort_order) + 1, 0)
77
+ FROM goals WHERE version_id = $1
78
+ RETURNING id, version_id, title`,
79
+ [versionId, title,
80
+ 'The ongoing home for copywriting and Artist Studio work in this version: page tweak rounds, ' +
81
+ 'the engineer tasks they leave, and copy proposals. Every version carries one (task 1004819).',
82
+ []]
83
+ );
84
+ const goal = made[0] || null;
85
+ if (!goal) return null;
86
+ await seedCriteria(exec, versionId, goal.id);
87
+ return goal;
88
+ }
89
+
90
+ async function seedCriteria(exec, versionId, goalId) {
91
+ let order = 0;
92
+ for (const c of ARTIST_REVIEW_CRITERIA) {
93
+ order += 1;
94
+ await exec.query(
95
+ `INSERT INTO done_when_criteria (version_id, criterion_id, criterion_md, goal_id, sort_order)
96
+ SELECT $1, $2, $3, $4, COALESCE(MAX(sort_order), 0) + $5 FROM done_when_criteria WHERE version_id = $1
97
+ ON CONFLICT (version_id, criterion_id) DO NOTHING`,
98
+ [versionId, c.criterion_id, c.criterion_md, goalId, order]
99
+ );
100
+ }
101
+ }
102
+
103
+ module.exports = { ensureArtistReviewGoal, artistReviewGoalTitle, ARTIST_REVIEW_CRITERIA };
@@ -21,6 +21,7 @@ const { executorFor } = require('./db-executor.js');
21
21
  const { isReservedCriterionSlug } = require('./criterion-ref.js');
22
22
  const { resolveWriteDiscipline, WRITABLE_DISCIPLINES } = require('./task-classifier.js');
23
23
  const { gateOnSpecTask } = require('./goal-spec-task.js');
24
+ const { ensureArtistReviewGoal } = require('./artist-review-goal.js');
24
25
 
25
26
  // -------------------------------------------------------------------------
26
27
  // Tasks
@@ -158,6 +159,11 @@ async function createTask({
158
159
  // copy-desk. Grep for it before adding a third: the answer is almost always
159
160
  // to name a goal instead.
160
161
  allowCatchAll = false,
162
+ // task 1004819: copywriting / Artist Studio work. With no goalId it lands in
163
+ // the version's artist review goal (created on first use, artist-review-goal.js)
164
+ // rather than the catch-all; the catch-all is only the fallback for a version
165
+ // that cannot take one.
166
+ artistReview = false,
161
167
  }, deps = {}) { // deps.client enlists this INSERT in the CALLER'S transaction (task 1003065, BV1.R01); the argument for it is at modules/ideas/inbox.js routeIdeaToGoal
162
168
  // task 1677 / ADR 0096 (2026-06-28): creation is now the PRIMARY reward-assignment
163
169
  // point — every task is born with a fair drachmae value, not just at promote/claim.
@@ -196,8 +202,18 @@ async function createTask({
196
202
  // opt-ins can name a real goal. Owner's call, recorded here so the remaining
197
203
  // gap is not mistaken for an oversight.
198
204
  let goalIdEffective = goalId;
205
+ if (goalIdEffective == null && artistReview === true && !deps.client) {
206
+ // No caller transaction (a copy proposal): run the whole create in one, so the
207
+ // goal it may create and the task commit together or not at all.
208
+ return api.withTx((client) => createTask(arguments[0], { ...deps, client }),
209
+ { pool: (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool });
210
+ }
211
+ if (goalIdEffective == null && artistReview === true) {
212
+ const artistGoal = await ensureArtistReviewGoal(versionId, { client: deps.client });
213
+ if (artistGoal) goalIdEffective = artistGoal.id;
214
+ }
199
215
  if (goalIdEffective == null) {
200
- if (allowCatchAll !== true) {
216
+ if (allowCatchAll !== true && artistReview !== true) {
201
217
  throw new Error(
202
218
  'createTask: goalId is required — every task belongs to a goal (BV1.R11). ' +
203
219
  'A vector that genuinely cannot name one yet must pass allowCatchAll: true, ' +
@@ -20,6 +20,7 @@ const { executorFor, inTransaction } = require('./db-executor.js');
20
20
  // (fitness pins the count). createVersion needs it to seed a version's FIRST goal
21
21
  // in the same transaction — task 1003701.
22
22
  const { createGoal } = require('./db-goals.js');
23
+ const { ensureArtistReviewGoal } = require('./artist-review-goal.js');
23
24
  const { cancelPendingMembershipRequests } = require('./goal-close-sweep.js');
24
25
 
25
26
  async function listVersions() {
@@ -214,7 +215,10 @@ async function createVersion({ id, name, status = 'planning', scopeDocPath = nul
214
215
  if (cr[0]) createdCriteria.push(cr[0]);
215
216
  i++;
216
217
  }
217
- return { version, goal: goal || null, criteria: createdCriteria };
218
+ // task 1004819: every version is born with its artist review goal and its two
219
+ // standing criteria, on the same client, so they roll back with the version.
220
+ const artistReviewGoal = await ensureArtistReviewGoal(id, { client });
221
+ return { version, goal: goal || null, criteria: createdCriteria, artistReviewGoal };
218
222
  }, { pool: txPool });
219
223
  }
220
224
 
@@ -156,9 +156,9 @@ async function webClaimPageTweak({ pageId, builderId, versionId = null, round },
156
156
  const n = Math.max(rounds.length, ...rounds.map((t) => roundNumber(t.source_ref))) + 1;
157
157
  const task = await createTask({
158
158
  versionId,
159
- // ADR 0341 D2: a round lands in the building version's catch-all goal,
160
- // exactly as a copy proposal does. Declared out loud (BV1.R11).
161
- allowCatchAll: true,
159
+ // ADR 0341 D2, task 1004819: a round lands in the building version's
160
+ // artist review goal, exactly as a copy proposal does.
161
+ artistReview: true,
162
162
  title: round.title(n),
163
163
  description: round.description(n),
164
164
  status: 'ready',
@@ -35,6 +35,7 @@
35
35
  'use strict';
36
36
 
37
37
  const api = require('../../src/module-api');
38
+ const { ensureArtistReviewGoal } = require('./artist-review-goal.js');
38
39
  const db = require('./db');
39
40
 
40
41
  function activePoolFor(deps) {
@@ -139,14 +140,21 @@ async function submitPageTweak({ taskId, builderId, versionId = null, freeze },
139
140
  );
140
141
  for (const f of found) if (!existing.has(f.source_ref)) existing.set(f.source_ref, String(f.id));
141
142
  }
143
+ // The version's artist review goal, resolved ONCE for the whole loop (task
144
+ // 1004819) rather than once per line: the version cannot change mid-submit.
145
+ const roundVersionId = versionId != null ? versionId : held.task.version_id;
146
+ const artistGoal = wanted.some((u) => !existing.has(u.sourceRef))
147
+ ? await ensureArtistReviewGoal(roundVersionId, { client }) : null;
142
148
  const filed = [];
143
149
  for (const u of wanted) {
144
150
  if (existing.has(u.sourceRef)) { filed.push({ id: existing.get(u.sourceRef), key: u.key, created: false }); continue; }
145
151
  const t = await createTask({
146
- versionId: versionId != null ? versionId : held.task.version_id,
147
- // The building version's catch-all goal, declared out loud (BV1.R11),
148
- // exactly as the round itself and a copy proposal land.
149
- allowCatchAll: true,
152
+ versionId: roundVersionId,
153
+ // The version's artist review goal (task 1004819), exactly as the
154
+ // round itself and a copy proposal land; artistReview is the fallback
155
+ // should the goal above not resolve.
156
+ goalId: artistGoal ? artistGoal.id : null,
157
+ artistReview: true,
150
158
  title: u.title,
151
159
  description: u.description,
152
160
  // BACKLOG (ADR 0341 D4): an engineer's triage decides when, and the
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.173",
3
+ "version": "1.21.175",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.173",
9
+ "version": "1.21.175",
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.21.173",
3
+ "version": "1.21.175",
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",
@@ -10120,5 +10120,21 @@
10120
10120
  "id": "1003808",
10121
10121
  "text": "The module store now ranks the best-scoring modules first, uses price only to break ties (free first), and lets buyers hide anything above a price they set."
10122
10122
  }
10123
+ ],
10124
+ "1.21.174": [
10125
+ {
10126
+ "id": "1004791",
10127
+ "text": "The city's cards no longer cut off their words. Long questions show in full, answer buttons say their whole answer, and a \"Full text\" link on each card opens everything the item says when there is more to read. Long goal names"
10128
+ },
10129
+ {
10130
+ "id": "1004819",
10131
+ "text": "Every version now has an artist review goal, holding all copywriting and Artist Studio work, with two standing checks: every page reviewed by an artist before the version closes, and every page simple to read and use. New page"
10132
+ }
10133
+ ],
10134
+ "1.21.175": [
10135
+ {
10136
+ "id": "1004820",
10137
+ "text": "Actions in the hall now use the city's ask card: a question, then one big button per answer that says what it does. Home's inbox (join invites and requests, the nod, removals, stale work) and the Blockers page and Board Room v"
10138
+ }
10123
10139
  ]
10124
10140
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.173'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.175'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');