@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.
- package/.bongos-core.json +65 -40
- package/docs/copy-inventory.md +56 -62
- package/docs/copy-registry.json +91 -156
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +5 -0
- package/docs/page-readings.json +64 -61
- package/modules/copy-desk/routes/copy-desk.js +4 -4
- package/modules/hall-ui/public/blockers.html +2 -0
- package/modules/hall-ui/public/blockers.js +13 -0
- package/modules/hall-ui/public/board-room.css +5 -0
- package/modules/hall-ui/public/board-room.html +1 -0
- package/modules/hall-ui/public/board-room.js +2 -2
- package/modules/hall-ui/public/city-decide.js +36 -2
- package/modules/hall-ui/public/city.css +8 -2
- package/modules/hall-ui/public/city.html +5 -5
- package/modules/hall-ui/public/goal-inbox.js +69 -86
- package/modules/hall-ui/public/hall-ask.css +57 -0
- package/modules/hall-ui/public/hall-ask.js +50 -0
- package/modules/hall-ui/public/index.html +3 -0
- package/modules/hall-ui/records/hall-ask-card.md +24 -0
- package/modules/lifecycle/artist-review-goal.js +103 -0
- package/modules/lifecycle/db-tasks.js +17 -1
- package/modules/lifecycle/db-versions.js +5 -1
- package/modules/lifecycle/page-tweak-claim.js +3 -3
- package/modules/lifecycle/page-tweak-write.js +12 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/module-api.js +1 -1
- package/tests/artist_review_goal.mjs +119 -0
- package/tests/blockers_hall.mjs +3 -1
- package/tests/copy_desk_page_claim.mjs +6 -1
- package/tests/copy_desk_page_submit.mjs +1 -1
- package/tests/fixtures/page-ledger.mjs +13 -0
- package/tests/hall_city_rewording.mjs +11 -0
- package/tests/home_attention_cards.mjs +2 -0
- package/tests/rot_inbox.mjs +10 -7
- 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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 ? ` · ${
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
: '
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
442
|
-
|
|
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
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
21
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
-
|
|
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
|
|
160
|
-
// exactly as a copy proposal does.
|
|
161
|
-
|
|
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:
|
|
147
|
-
// The
|
|
148
|
-
//
|
|
149
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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.
|
|
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');
|