@bongos/core 1.23.14 → 1.23.16

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 (62) hide show
  1. package/.bongos-core.json +96 -61
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +22 -1
  8. package/docs/api-reference.md +3 -2
  9. package/docs/copy-inventory.md +932 -886
  10. package/docs/copy-registry.json +1807 -1336
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-inventory.json +8 -3
  13. package/docs/page-readings.json +578 -558
  14. package/modules/copy-desk/page-decisions.js +32 -4
  15. package/modules/copy-desk/page-status.js +19 -10
  16. package/modules/copy-desk/pages.js +61 -0
  17. package/modules/copy-desk/routes/copy-desk.js +7 -3
  18. package/modules/copy-desk/skills/tweak/SKILL.md +1 -1
  19. package/modules/copy-desk/tests/copy_page_next.mjs +1 -1
  20. package/modules/copy-desk/tests/copy_page_status.mjs +9 -4
  21. package/modules/hall-ui/public/approval-queue-lib.js +10 -3
  22. package/modules/hall-ui/public/approval-queue.js +7 -5
  23. package/modules/hall-ui/public/studio-lib.js +3 -2
  24. package/modules/lifecycle/lifecycle.js +2 -0
  25. package/modules/lifecycle/page-tweak-approve.js +50 -2
  26. package/modules/platform-identity/builder-of-the-week.js +180 -0
  27. package/modules/platform-identity/migrations/platform_identity_032_weekly_features.sql +53 -0
  28. package/modules/platform-identity/routes/community-leaderboard.js +25 -1
  29. package/modules/public-landing/public/account.html +2 -1
  30. package/modules/public-landing/public/community-week-none.states.json +16 -0
  31. package/modules/public-landing/public/community.html +142 -13
  32. package/modules/public-landing/public/community.states.json +6 -4
  33. package/modules/public-landing/public/connections.html +2 -1
  34. package/modules/public-landing/public/contact.html +2 -1
  35. package/modules/public-landing/public/guild.html +2 -1
  36. package/modules/public-landing/public/index.html +2 -1
  37. package/modules/public-landing/public/invitations.html +2 -1
  38. package/modules/public-landing/public/privacy.html +2 -1
  39. package/modules/public-landing/public/projects.html +2 -1
  40. package/modules/public-landing/public/terms.html +2 -1
  41. package/modules/ui-design/kit/fixtures/community-week-none.json +6 -0
  42. package/modules/ui-design/kit/fixtures/community-week.json +11 -0
  43. package/modules/ui-design/kit/serve.js +8 -0
  44. package/package-lock.json +2 -2
  45. package/package.json +1 -1
  46. package/release-notes.json +12 -0
  47. package/scripts/bongos/run-unit-tests.js +4 -0
  48. package/src/module-api.js +1 -1
  49. package/src/platform-server.js +5 -5
  50. package/tests/builder_of_the_week.mjs +266 -0
  51. package/tests/builder_of_the_week_db.mjs +166 -0
  52. package/tests/copy_desk_page_approve.mjs +53 -0
  53. package/tests/copy_desk_page_editor.mjs +2 -2
  54. package/tests/copy_desk_page_next.mjs +1 -1
  55. package/tests/copy_desk_page_release.mjs +1 -1
  56. package/tests/copy_desk_page_submit.mjs +2 -2
  57. package/tests/copy_stuck_page_tweaks.mjs +12 -3
  58. package/tests/hall_approval_queue.mjs +6 -6
  59. package/tests/hub_account_page.mjs +2 -2
  60. package/tests/hub_community_page.mjs +136 -25
  61. package/tests/landing_legal_pages.mjs +2 -2
  62. package/tests/privacy_matrix.mjs +53 -8
@@ -0,0 +1,166 @@
1
+ // tests/builder_of_the_week_db.mjs — task 1002981 (community spec D5; real DB).
2
+ //
3
+ // Proves Builder of the Week against real SQL: which snapshots open and close the
4
+ // week, the shipped-work floor, the tie order, the leaderboard's eligibility, that
5
+ // the first decision is pinned (a later change, a hide, a race) and that a week
6
+ // nobody won is recorded empty. A fake pool can only prove the statements' shape
7
+ // (tests/builder_of_the_week.mjs).
8
+ //
9
+ // The crown is global (every eligible builder on the hub), so every fixture week is
10
+ // in 2099: a shared database's real builders have no snapshot there, their week's
11
+ // delta is zero, and the shipped-work floor keeps them out.
12
+ //
13
+ // Real-DB integration test: self-skips without a reachable Postgres; in the
14
+ // INTEGRATION set (scripts/bongos/run-unit-tests.js) so the DB-free lane never runs it.
15
+ //
16
+ // Run: DATABASE_URL=... node tests/builder_of_the_week_db.mjs
17
+
18
+ import { createRequire } from 'node:module';
19
+ import { readFileSync } from 'node:fs';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { dirname, join } from 'node:path';
22
+ import { strict as assert } from 'node:assert';
23
+ const require = createRequire(import.meta.url);
24
+ const { Pool } = require('pg');
25
+ const { readBuilderOfTheWeek, crownedWeek, clearCrownMemo } = require('../modules/platform-identity/builder-of-the-week.js');
26
+
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
29
+ const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
30
+
31
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
32
+ try {
33
+ await pool.query('SELECT 1');
34
+ } catch (e) {
35
+ console.log(`builder_of_the_week_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
36
+ await pool.end().catch(() => {});
37
+ process.exit(0);
38
+ }
39
+
40
+ const A = 90298101, B = 90298102, C = 90298103, D = 90298104;
41
+ const HIDDEN = 90298105, PRIVATE = 90298106, E = 90298107;
42
+ const IDS = [A, B, C, D, E, HIDDEN, PRIVATE];
43
+ const P1 = 't1002981-p1', P2 = 't1002981-p2';
44
+ const DAY = 24 * 60 * 60 * 1000;
45
+ const NOW = new Date('2099-03-12T10:00:00.000Z');
46
+ const { start, end } = crownedWeek(NOW);
47
+ const at = (base, ms) => new Date(base.getTime() + ms);
48
+ const q = (sql, params) => pool.query(sql, params);
49
+
50
+ async function cleanup() {
51
+ await q(`DELETE FROM platform_identity_weekly_features WHERE week_start >= '2099-01-01'`).catch(() => {});
52
+ await q(`DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
53
+ await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
54
+ await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
55
+ }
56
+ async function account(id, handle, { visibility = 'public', hide = false } = {}) {
57
+ await q(
58
+ `INSERT INTO platform_identity_accounts
59
+ (github_id, github_login, display_name, handle, profile_state, account_visibility, hide_stats, terms_accepted_at)
60
+ VALUES ($1, $2, $2, $2, 'active', $3, $4, now())`,
61
+ [id, handle, visibility, hide],
62
+ );
63
+ }
64
+ // The live row (its numbers do not matter to a past week; first seen long before the
65
+ // week unless said) and a history of snapshots.
66
+ async function builder(id, client, history, firstSeen = at(start, -365 * DAY)) {
67
+ await q(`INSERT INTO platform_identity_builder_project_activity (client_id, github_id, credits, tasks_shipped, karma, first_seen_at) VALUES ($1, $2, 0, 0, 0, $3)`, [client, id, firstSeen]);
68
+ for (const [when, credits, ships, karma] of history) {
69
+ await q(
70
+ `INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at) VALUES ($1, $2, $3, $4, $5, $6)`,
71
+ [client, id, credits, ships, karma, when],
72
+ );
73
+ }
74
+ }
75
+ const recorded = async (week) => (await q(
76
+ `SELECT github_id::text AS github_id, credits_delta::int AS credits, ships_delta::int AS ships FROM platform_identity_weekly_features WHERE week_start = $1::date`,
77
+ [week],
78
+ )).rows;
79
+ const read = (now = NOW) => { clearCrownMemo(); return readBuilderOfTheWeek({ now, pool }); };
80
+
81
+ let pass = 0;
82
+ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
83
+ const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
84
+
85
+ try {
86
+ for (const f of [
87
+ 'platform_identity_001_tables.sql', 'platform_identity_005_builder_activity.sql',
88
+ 'platform_identity_007_profile_consent.sql', 'platform_identity_008_handle.sql',
89
+ 'platform_identity_012_account_privacy.sql', 'platform_identity_019_terms_acceptance.sql',
90
+ 'platform_identity_022_activity_snapshots.sql', 'platform_identity_032_weekly_features.sql',
91
+ ]) await q(migrationSql(f));
92
+ await cleanup();
93
+
94
+ await account(A, 't1002981-a');
95
+ await account(B, 't1002981-b');
96
+ await account(C, 't1002981-c');
97
+ await account(D, 't1002981-d');
98
+ await account(E, 't1002981-e');
99
+ await account(HIDDEN, 't1002981-hidden', { hide: true });
100
+ await account(PRIVATE, 't1002981-private', { visibility: 'private' });
101
+
102
+ // A: 350 credits, 3 works, no karma — across two projects. P1 has a snapshot before
103
+ // the week (the baseline); P2 has none but was first seen in the week (counts from
104
+ // zero). A snapshot a second AFTER the week closes is not the week's.
105
+ await builder(A, P1, [[at(start, -3 * DAY), 100, 5, 4], [at(start, 2 * DAY), 400, 7, 4], [at(end, 1000), 99999, 99, 4]]);
106
+ await builder(A, P2, [[at(start, 3 * DAY), 50, 1, 0]], at(start, 3 * DAY));
107
+ // E: a lifetime brought into the week — no history before it, and seen before it (or
108
+ // before first_seen_at existed, NULL). Counting from zero would crown E; neither counts.
109
+ await builder(E, P1, [[at(start, DAY), 90000, 40, 0]], null);
110
+ await builder(E, P2, [[at(start, DAY), 90000, 40, 0]]);
111
+ // C: the same credits and works as A, two more karma — C beats A on karma.
112
+ await builder(C, P1, [[at(start, -DAY), 0, 0, 0], [at(start, DAY), 360, 3, 2]]);
113
+ // D: the same credits, the most karma, but one work fewer — works come before karma.
114
+ await builder(D, P1, [[at(start, -DAY), 0, 0, 0], [at(start, DAY), 350, 2, 9]]);
115
+ // B: the most credits, but no work shipped in the week (the floor).
116
+ await builder(B, P1, [[at(start, -DAY), 0, 4, 0], [at(start, DAY), 900, 4, 0]]);
117
+ // HIDDEN and PRIVATE out-earn everyone, and the leaderboard may not show them.
118
+ await builder(HIDDEN, P1, [[at(start, -DAY), 0, 0, 0], [at(start, DAY), 5000, 5, 0]]);
119
+ await builder(PRIVATE, P1, [[at(start, -DAY), 0, 0, 0], [at(start, DAY), 5000, 5, 0]]);
120
+ // A snapshot exactly at the start belongs to the baseline: C's week is 360 - 10, not 360.
121
+ await q(`INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at) VALUES ($1, $2, 10, 0, 0, $3)`, [P1, C, start]);
122
+
123
+ // 1 — the crown, and the row that records it
124
+ const first = await read();
125
+ same(first.week, { start: start.toISOString().slice(0, 10), end: end.toISOString().slice(0, 10) }, 'the week is the last full Monday-to-Monday week');
126
+ same(first.winner && [first.winner.handle, first.winner.credits, first.winner.tasks_shipped], ['t1002981-c', 350, 3], 'ties go to works shipped, then karma; no work, hidden, private and a lifetime brought into the week never win');
127
+ ok(!('github_id' in first.winner), 'github_id never crosses');
128
+ same(await recorded(first.week.start), [{ github_id: String(C), credits: 350, ships: 3 }], 'the week is recorded on the first read');
129
+
130
+ // 2 — pinned: a later report into the past week, or a builder who goes public, changes nothing
131
+ await q(`INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at) VALUES ($1, $2, 99999, 50, 0, $3)`, [P1, A, at(start, 4 * DAY)]);
132
+ await q(`UPDATE platform_identity_accounts SET hide_stats = false WHERE github_id = $1`, [HIDDEN]);
133
+ same((await read()).winner.handle, 't1002981-c', 'the first decision stands');
134
+ await q(`UPDATE platform_identity_accounts SET hide_stats = true WHERE github_id = $1`, [HIDDEN]);
135
+
136
+ // 3 — a winner who hides is not shown, and their week goes to nobody else
137
+ await q(`UPDATE platform_identity_accounts SET hide_stats = true WHERE github_id = $1`, [C]);
138
+ same((await read()).winner, null, 'a hidden winner is not drawn');
139
+ same(await recorded(first.week.start), [{ github_id: String(C), credits: 350, ships: 3 }], 'and the row is never re-crowned');
140
+
141
+ // 4 — a later week nobody shipped in (two on, past A's late snapshot) is recorded empty;
142
+ // the crowned week is in its past list once its winner is shown again
143
+ const next = at(NOW, 14 * DAY);
144
+ const empty = await read(next);
145
+ same(empty.winner, null, 'a week nobody won has no crown');
146
+ same(await recorded(empty.week.start), [{ github_id: null, credits: 0, ships: 0 }], 'and is recorded empty, so it stays empty');
147
+ same(empty.past, [], 'the week before is left out while its winner hides');
148
+ await q(`UPDATE platform_identity_accounts SET hide_stats = false WHERE github_id = $1`, [C]);
149
+ same((await read(next)).past.map((p) => [p.week_start, p.handle]), [[first.week.start, 't1002981-c']], 'shown again, it is back in the past weeks');
150
+
151
+ // 5 — two hub processes crowning the same week at once: one row, no error
152
+ const third = at(NOW, 21 * DAY);
153
+ const other = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
154
+ try {
155
+ clearCrownMemo();
156
+ const both = await Promise.all([readBuilderOfTheWeek({ now: third, pool }), readBuilderOfTheWeek({ now: third, pool: other })]);
157
+ same(both.map((b) => b.winner), [null, null], 'both answer');
158
+ same((await recorded(both[0].week.start)).length, 1, 'one row: ON CONFLICT DO NOTHING');
159
+ } finally { await other.end().catch(() => {}); }
160
+
161
+ console.log(`\nbuilder_of_the_week_db.mjs: ${pass} passed`);
162
+ } finally {
163
+ clearCrownMemo();
164
+ await cleanup();
165
+ await pool.end().catch(() => {});
166
+ }
@@ -288,6 +288,59 @@ try {
288
288
  assert.equal(pages.parseSentBackBlocks(task(5002).description).length, 2, 'send-backs append');
289
289
  });
290
290
 
291
+ // task 1004900: the WORDS of a submitted batch are reviewed before anything is
292
+ // applied. Approve puts the round in the /tweak queue; send-back withdraws the
293
+ // batch so the round is a draft its artist can change.
294
+ await test('words first: a submitted batch is in the queue as words, not applied, and only an approval sends it to /tweak', async () => {
295
+ const STUDIO = '/copy-desk/pages/builders:studio';
296
+ const line = { key: 'L0001', section: 'top', before: 'Welcome home', after: 'Welcome back', placement: 'placed', file: 'x.html', line: 1, string_id: 'abc' };
297
+ const desc = () => [
298
+ 'Page tweak round 1 for builders:studio.',
299
+ pages.composeDraftBlock({ page_id: 'builders:studio', reading_hash: 'h', saved_at: '2026-09-23T00:00:00Z', lines: [line] }),
300
+ pages.composeBatchBlock({ page_id: 'builders:studio', reading_hash: 'h', submitted_at: '2026-09-23T00:00:00Z', lines: [line] }),
301
+ ].join('\n\n');
302
+ db.tasks.push({ id: '7001', status: 'ready', title: 'Tweak: Studio', description: desc(), source: 'page-tweak', source_ref: pages.roundSourceRef('builders:studio', 1), touches: [], version_id: 'V9', goal_id: '77' });
303
+ db.claims.push({ id: String(++db.seq.claim), task_id: '7001', builder_id: ALICE, worktree_name: null, creator_session_id: null, claimed_at: stamp(), released_at: stamp(), outcome: 'partial' });
304
+
305
+ assert.equal((await call('GET', STUDIO, ALICE)).body.open_round.state, 'words_review');
306
+ const q = (await call('GET', '/copy-desk/approvals', BOB)).body;
307
+ const item = q.queue.find((x) => x.task_id === '7001');
308
+ assert.ok(item, 'the submitted words are in the queue');
309
+ assert.equal(item.kind, 'words');
310
+ assert.equal(item.applied_at, null);
311
+ assert.deepEqual(item.lines.map((l) => [l.before, l.after]), [['Welcome home', 'Welcome back']]);
312
+ assert.equal(q.not_applied.some((r) => r.task_id === '7001'), false, 'not listed as approved-and-waiting');
313
+
314
+ const dan = await call('POST', `${STUDIO}/approve`, DAN, { task_id: '7001' });
315
+ assert.equal(errCode(dan.body), 'not_an_artist');
316
+ const short = await call('POST', `${STUDIO}/send-back`, BOB, { note: 'nope' });
317
+ assert.equal(errCode(short.body), 'note_too_short');
318
+
319
+ const ok = await call('POST', `${STUDIO}/approve`, BOB, { task_id: '7001' });
320
+ assert.equal(ok.status, 200, JSON.stringify(ok.body));
321
+ assert.equal(ok.body.outcome, 'words_approved');
322
+ assert.deepEqual(ok.body.round, { task_id: '7001', state: 'submitted', status: 'ready' });
323
+ assert.equal(task(7001).status, 'ready', 'nothing applied or landed: the task only gained the approval');
324
+ assert.deepEqual(pages.parseWordsApprovedBlocks(task(7001).description).map((a) => a.by), [BOB]);
325
+ assert.equal((await call('GET', STUDIO, ALICE)).body.open_round.state, 'submitted', 'now in the /tweak queue');
326
+ const after = (await call('GET', '/copy-desk/approvals', BOB)).body;
327
+ assert.equal(after.queue.some((x) => x.task_id === '7001'), false);
328
+ assert.equal(after.not_applied.some((r) => r.task_id === '7001'), true, 'listed as approved, waiting to be applied');
329
+ const twice = await call('POST', `${STUDIO}/approve`, BOB);
330
+ assert.equal(errCode(twice.body), 'not_waiting', 'an approved batch is not decided again');
331
+
332
+ // A second round of words, sent back: the batch goes, the draft stays, the note rests on the task.
333
+ task(7001).description = desc();
334
+ const back = await call('POST', `${STUDIO}/send-back`, ALICE, { note: 'I want to say this differently.', task_id: '7001' });
335
+ assert.equal(back.status, 200, JSON.stringify(back.body));
336
+ assert.equal(back.body.outcome, 'words_sent_back');
337
+ assert.equal(pages.parseBatchBlock(task(7001).description).ok, false, 'the batch is withdrawn');
338
+ assert.equal(pages.parseDraftBlock(task(7001).description).ok, true, 'the draft stays for its writer');
339
+ assert.deepEqual(pages.parseSentBackBlocks(task(7001).description).map((s) => s.note), ['I want to say this differently.']);
340
+ assert.equal((await call('GET', STUDIO, ALICE)).body.open_round.state, 'draft', 'a draft again, open for writing');
341
+ assert.equal(pages.roundOpenForWriting(task(7001)), true);
342
+ });
343
+
291
344
  await test('an ordinary task and a failed grade are never moved through the port', async () => {
292
345
  db.tasks.push({ id: '6001', status: 'completed', title: 'x', description: '', source: 'planning-session', source_ref: 'x', touches: [] });
293
346
  db.grades.set('6001', true);
@@ -144,10 +144,10 @@ try {
144
144
  writeReadings(readingsWith('studio-h1'));
145
145
  });
146
146
 
147
- await test('after submit the round reads submitted and nobody holds it', async () => {
147
+ await test('after submit the round waits for its words to be reviewed and nobody holds it', async () => {
148
148
  assert.equal((await call('POST', `${STUDIO}/submit`, ALICE)).body.outcome, 'submitted');
149
149
  const r = await call('GET', `${STUDIO}/draft`, ALICE);
150
- assert.equal(r.body.round.state, 'submitted');
150
+ assert.equal(r.body.round.state, 'words_review', 'task 1004900: reviewed before it is applied');
151
151
  assert.equal(r.body.round.held_by_you, false);
152
152
  assert.equal(r.body.round.holder, null);
153
153
  });
@@ -154,7 +154,7 @@ try {
154
154
  round.status = 'ready';
155
155
  round.description += '\n\n' + pages.composeBatchBlock({ page_id: 'status:explore', reading_hash: 'h', submitted_at: 'x', lines: [{ key: 'L0001', section: 'hero', before: 'Explore', after: 'Look around', placement: 'placed', file: 'x', line: 1, string_id: 'a' }] });
156
156
  const next = (await getNext(ALICE)).body;
157
- assert.deepEqual(next.skipped, [{ page_id: 'status:explore', reason: 'page_in_flight', state: 'submitted' }]);
157
+ assert.deepEqual(next.skipped, [{ page_id: 'status:explore', reason: 'page_in_flight', state: 'words_review' }]);
158
158
  const refused = await call('POST', '/copy-desk/pages/status:explore/claim', ALICE, {});
159
159
  assert.equal(errCode(refused.body), 'page_in_flight');
160
160
  });
@@ -104,7 +104,7 @@ try {
104
104
  const flight = Object.fromEntries(r.body.in_flight.map((f) => [f.page_id, f]));
105
105
  assert.deepEqual([flight['landing:index'].state, flight['landing:index'].stuck, flight['landing:index'].task_id], ['unknown', true, '7001']);
106
106
  assert.deepEqual([flight['landing:projects'].state, flight['landing:projects'].stuck], ['unknown', true]);
107
- assert.equal(flight['status:explore'].state, 'submitted');
107
+ assert.equal(flight['status:explore'].state, 'words_review');
108
108
  assert.equal(flight['status:explore'].stuck, false);
109
109
  });
110
110
 
@@ -188,7 +188,7 @@ try {
188
188
  const r = await call('POST', `${STUDIO}/submit`, ALICE);
189
189
  assert.equal(r.status, 200, JSON.stringify(r.body));
190
190
  assert.equal(r.body.outcome, 'submitted');
191
- assert.deepEqual(r.body.round, { task_id: studioTask().id, state: 'submitted', status: 'ready' });
191
+ assert.deepEqual(r.body.round, { task_id: studioTask().id, state: 'words_review', status: 'ready' });
192
192
  assert.equal(r.body.batch.line_count, 2);
193
193
  assert.deepEqual(r.body.next, { path: '/copy-desk/next' });
194
194
 
@@ -229,7 +229,7 @@ try {
229
229
  await test('the page reads submitted, and nobody can claim it back into writing', async () => {
230
230
  const page = await call('GET', STUDIO, BOB);
231
231
  assert.equal(page.status, 200);
232
- assert.equal(page.body.open_round.state, 'submitted');
232
+ assert.equal(page.body.open_round.state, 'words_review');
233
233
  const claim = await call('POST', `${STUDIO}/claim`, BOB);
234
234
  assert.equal(errCode(claim.body), 'page_in_flight');
235
235
  });
@@ -23,7 +23,8 @@ const ago = (d) => new Date(NOW.getTime() - d * 86400000).toISOString();
23
23
  const pg = (id, title = id) => ({ id, surface: id.split(':')[0], title, path: '/', files: [] });
24
24
  const PAGES = [pg('landing:index', '{{productName}}: {{tagline}}'), pg('landing:account', 'Account'), pg('builders:studio', 'Studio'), pg('landing:projects', 'Projects')];
25
25
  const INVENTORY = { surfaces: [{ id: 'landing' }, { id: 'builders' }], pages: PAGES };
26
- const batch = (id) => pages.composeBatchBlock({ page_id: id, reading_hash: 'h', submitted_at: ago(9), lines: [] });
26
+ // Approved words (task 1004900): the round waits to be applied.
27
+ const batch = (id) => `${pages.composeBatchBlock({ page_id: id, reading_hash: 'h', submitted_at: ago(9), lines: [] })}\n\n${pages.composeWordsApprovedBlock({ at: ago(9), by: '7' })}`;
27
28
  const round = (n, id, extra) => ({ id: String(n), source: 'page-tweak', source_ref: pages.roundSourceRef(id, 1), description: pages.roundTaskBody(pg(id), 1), artist_id: '7', ...extra });
28
29
  const ARTIST = '7';
29
30
  const TASKS = [
@@ -45,6 +46,14 @@ await test('the composer: only stuck pages, each with where it sits, how long, a
45
46
  ]);
46
47
  });
47
48
 
49
+ await test('submitted words left unreviewed wait on their artist, not the appliers (task 1004900)', () => {
50
+ const words = round(9, 'landing:projects', { status: 'ready', description: `${pages.roundTaskBody(pg('landing:projects'), 1)}\n\n${pages.composeBatchBlock({ page_id: 'landing:projects', reading_hash: 'h', submitted_at: ago(4), lines: [] })}`, updated_at: ago(4) });
51
+ const got = status.stuckPageTweaks({ inventory: INVENTORY, tasks: [words], now: NOW });
52
+ assert.deepEqual(got.map((s) => [s.page_id, s.state, s.waits_on]), [['landing:projects', 'words_review', 'artist']]);
53
+ assert.deepEqual(status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'xenos', stuck: got }).map((s) => s.page_id), ['landing:projects']);
54
+ assert.deepEqual(status.stuckPageTweaksFor({ builderId: '99', rank: 'metic', stuck: got }), [], 'an applier is not asked to review words');
55
+ });
56
+
48
57
  await test('Home: the artist sees their own review; a Metic+ builder sees what waits to be applied', () => {
49
58
  const forArtist = status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'thetes', stuck: stuck() });
50
59
  assert.deepEqual(forArtist.map((s) => s.page_id), ['landing:account']);
@@ -57,7 +66,7 @@ await test('the real needs roster: one Needs-attention line per page, where and
57
66
  const r = needs.computeNeeds({ rank: 'archon', ownKeySet: true, stuckPageTweaks: status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'archon', stuck: stuck() }) });
58
67
  const lines = r.items.filter((n) => n.id.startsWith('page_tweak_stuck:'));
59
68
  assert.deepEqual(lines.map((n) => [n.id, n.title, n.message, n.state, n.action.href]), [
60
- ['page_tweak_stuck:landing:index', 'Page tweak stuck: landing:index', 'Submitted, waiting to be applied for 26 days.', 'action_needed', '/studio'],
69
+ ['page_tweak_stuck:landing:index', 'Page tweak stuck: landing:index', 'Approved, waiting to be applied for 26 days.', 'action_needed', '/studio'],
61
70
  ['page_tweak_stuck:landing:account', 'Page tweak stuck: Account', 'Waiting for your review for 5 days.', 'action_needed', '/studio'],
62
71
  ]);
63
72
  });
@@ -82,7 +91,7 @@ await test('the real digest: page names only, never copy, to the ship channel, a
82
91
  assert.equal(posts[0].channel, 'ship_feed');
83
92
  assert.equal(posts[0].text, [
84
93
  'Page tweaks stuck (2). Open the Studio to move them:',
85
- '- landing:index: submitted, waiting to be applied, 26 days',
94
+ '- landing:index: approved, waiting to be applied, 26 days',
86
95
  '- Account: waiting for your review, 5 days',
87
96
  ].join('\n'));
88
97
  const again = await stuckTweaks.runDigest({ nowMs: NOW.getTime() + 3600_000, state, post, readStuckImpl });
@@ -141,8 +141,8 @@ await test('lib: the artboard\'s words, from the route\'s own shape', () => {
141
141
  assert.equal(lib.displayTitle(home), 'Home', 'an apex template title is Home, the artboard\'s own word');
142
142
  assert.equal(lib.subLine(home), '· landing · 1 line rewritten, applied · 1 not applied');
143
143
  assert.equal(lib.subLine(projects), '· landing · 1 line rewritten, applied');
144
- assert.equal(lib.doorDetail(2), '2 pages applied, waiting for you');
145
- assert.equal(lib.doorDetail(1), '1 page applied, waiting for you');
144
+ assert.equal(lib.doorDetail(2), '2 pages waiting for you');
145
+ assert.equal(lib.doorDetail(1), '1 page waiting for you');
146
146
  assert.equal(lib.doorDetail(0), 'nothing waiting for you');
147
147
  assert.equal(lib.approvedLine(home, { status_after: 'Text tweaked 3x', next: { page: { title: 'Contact' } } }),
148
148
  'Approved. It ships now, and Home reads text tweaked 3x. Next in the queue: Contact.', 'the artboard line, word for word');
@@ -346,22 +346,22 @@ await test('submitted but not applied: listed apart, marked, how long, no verbs,
346
346
  const submitted = {
347
347
  id: 60, status: 'ready', holder_id: null, artist_id: ME, artist_login: 'me', updated_at: '2026-09-29T08:00:00Z',
348
348
  source_ref: pages.roundSourceRef('landing:projects', 2),
349
- description: ['Page tweak round 2.', pages.composeBatchBlock({ page_id: 'landing:projects', reading_hash: 'h', submitted_at: '2026-09-29T08:00:00Z', lines: [{ key: 'L0002', section: 'list', before: 'Every project', after: 'All of them', placement: 'placed', file: 'x.html', line: 1, string_id: 'a' }] })].join('\n\n'),
349
+ description: ['Page tweak round 2.', pages.composeBatchBlock({ page_id: 'landing:projects', reading_hash: 'h', submitted_at: '2026-09-29T08:00:00Z', lines: [{ key: 'L0002', section: 'list', before: 'Every project', after: 'All of them', placement: 'placed', file: 'x.html', line: 1, string_id: 'a' }] }), pages.composeWordsApprovedBlock({ at: '2026-09-29T09:00:00Z', by: ME })].join('\n\n'),
350
350
  };
351
351
  const tasks = [...TASKS.filter((t) => t.id !== 42), submitted];
352
352
  const data = pageStatus.composeApprovals({ inventory: INVENTORY, tasks, builderId: ME, crafts: ['artist'], now: NOW });
353
353
  assert.equal(data.count, 1, 'the door counts reviewable rounds only');
354
354
  assert.deepEqual(data.queue.map((q) => q.task_id), ['41']);
355
355
  assert.deepEqual(data.not_applied.map((r) => [r.page_id, r.task_id, r.state_word, r.waited_days, r.line_count]),
356
- [['landing:projects', '60', 'Submitted, waiting to be applied', 21, 1]]);
356
+ [['landing:projects', '60', 'Approved, waiting to be applied', 21, 1]]);
357
357
 
358
358
  const b = bootPanel({ answers: { [APPROVALS]: { ok: true, ...data }, [vis(41)]: slotsFor(41) } });
359
359
  await b.panel.open(); await settle(); await settle();
360
- assert.equal(text(b.q('.aq-pending__h')), 'Submitted, not applied yet (1)');
360
+ assert.equal(text(b.q('.aq-pending__h')), 'Approved, not applied yet (1)');
361
361
  const row = b.q('.aq-pending__row');
362
362
  assert.match(text(row), /Projects/);
363
363
  assert.match(text(row), /not applied yet/);
364
- assert.match(text(row), /Submitted, waiting to be applied · waiting 21 days\. The words are waiting to be applied/);
364
+ assert.match(text(row), /Approved, waiting to be applied · waiting 21 days\. The words are approved and waiting to be put on the page/);
365
365
  assert.equal(row.querySelectorAll('button').length, 0, 'no approve or send-back on a page nobody applied');
366
366
  assert.deepEqual(b.counts, [1]);
367
367
 
@@ -135,8 +135,8 @@ test('the bands say what the hub’s say and lead into /projects; Account is the
135
135
  assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProjects')), [
136
136
  ['Sky', '/projects?view=map'], ['Table', '/projects?view=map&sub=table'],
137
137
  ['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
138
- // the Community menu's three sub-pages, folded into the band under 480px (task 1003363)
139
- ['Leaderboard', '/community'], ['Find a builder', '/community/find'], ['Open to recruiters', '/community/recruiters'],
138
+ // the Community menu's four sub-pages, folded into the band under 480px (tasks 1003363, 1002981)
139
+ ['Leaderboard', '/community'], ['Builder of the week', '/community/week'], ['Find a builder', '/community/find'], ['Open to recruiters', '/community/recruiters'],
140
140
  ['Builders you may know', '/connections#connSuggest'],
141
141
  ], 'this page has no views of its own, so every item names /projects in full');
142
142
  assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProfile')), [