@bongos/core 1.20.58 → 1.20.60

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 (119) hide show
  1. package/.bongos-core.json +198 -108
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +10 -0
  4. package/clients/bongos-client/index.cjs +10 -0
  5. package/clients/bongos-client/index.d.ts +15 -2
  6. package/clients/bongos-client/index.mjs +10 -0
  7. package/docs/adr/0358-a-hall-wears-a-look-and-its-night-takes-a-tint.md +52 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +317 -6
  10. package/docs/api-reference.md +8 -3
  11. package/docs/architecture.md +1 -0
  12. package/docs/copy-inventory.md +339 -330
  13. package/docs/copy-registry.json +524 -439
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +4 -1
  16. package/docs/page-readings.json +729 -678
  17. package/migrations/core_268_builders_main_discipline.sql +35 -0
  18. package/modules/copy-desk/artist-stats.js +56 -0
  19. package/modules/copy-desk/module.json +1 -1
  20. package/modules/copy-desk/page-status.js +39 -0
  21. package/modules/copy-desk/routes/copy-desk.js +7 -16
  22. package/modules/copy-desk/tests/copy_no_cms.mjs +10 -1
  23. package/modules/economy/credits-by-craft.js +208 -0
  24. package/modules/economy/reward.js +8 -0
  25. package/modules/hall-ui/public/atlas.html +1 -1
  26. package/modules/hall-ui/public/blockers.html +1 -1
  27. package/modules/hall-ui/public/board-room.html +1 -1
  28. package/modules/hall-ui/public/collab.html +1 -1
  29. package/modules/hall-ui/public/commands.html +1 -1
  30. package/modules/hall-ui/public/copy-desk.html +1 -1
  31. package/modules/hall-ui/public/deploy.html +1 -1
  32. package/modules/hall-ui/public/diagrams.html +1 -1
  33. package/modules/hall-ui/public/drachmae.html +1 -1
  34. package/modules/hall-ui/public/fleet.html +1 -1
  35. package/modules/hall-ui/public/gate.html +1 -1
  36. package/modules/hall-ui/public/goal-map.html +1 -1
  37. package/modules/hall-ui/public/goals.html +1 -1
  38. package/modules/hall-ui/public/government.html +1 -1
  39. package/modules/hall-ui/public/hall-render.js +2 -2
  40. package/modules/hall-ui/public/idea.html +1 -1
  41. package/modules/hall-ui/public/ideas.html +1 -1
  42. package/modules/hall-ui/public/index.html +1 -1
  43. package/modules/hall-ui/public/modules.html +1 -1
  44. package/modules/hall-ui/public/primer.html +1 -1
  45. package/modules/hall-ui/public/profile.html +1 -1
  46. package/modules/hall-ui/public/profile.js +1 -1
  47. package/modules/hall-ui/public/project-settings.html +1 -1
  48. package/modules/hall-ui/public/ranks.html +1 -1
  49. package/modules/hall-ui/public/roadmap.html +1 -1
  50. package/modules/hall-ui/public/roster.html +1 -1
  51. package/modules/hall-ui/public/sessions.html +1 -1
  52. package/modules/hall-ui/public/settings.html +1 -1
  53. package/modules/hall-ui/public/shell.js +5 -1
  54. package/modules/hall-ui/public/studio.html +1 -1
  55. package/modules/hall-ui/public/style.css +8 -0
  56. package/modules/hall-ui/public/task.html +1 -1
  57. package/modules/hall-ui/public/thinking.html +1 -1
  58. package/modules/hall-ui/public/tweak-editor.html +1 -1
  59. package/modules/hall-ui/public/watch.html +1 -1
  60. package/modules/hall-ui/public/work.html +1 -1
  61. package/modules/ideas/ideator-stats.js +149 -0
  62. package/modules/ideas/module.json +2 -1
  63. package/modules/ideas/routes/inbox.js +7 -0
  64. package/modules/lifecycle/activity.js +5 -2
  65. package/modules/lifecycle/db-analytics.js +4 -2
  66. package/modules/lifecycle/db-tasks.js +49 -1
  67. package/modules/lifecycle/db.js +3 -2
  68. package/modules/lifecycle/page-tweak-reads.js +23 -5
  69. package/modules/provisioning/look.js +169 -0
  70. package/modules/provisioning/migrations/provisioning_034_project_logo.sql +35 -0
  71. package/modules/provisioning/module.json +2 -1
  72. package/modules/provisioning/provisioning.js +4 -3
  73. package/modules/provisioning/routes/look.js +204 -0
  74. package/modules/provisioning/routes/provisioning.js +5 -59
  75. package/modules/provisioning/settings-push.js +76 -0
  76. package/modules/public-landing/public/assets/cosmos.css +1 -1
  77. package/modules/public-landing/public/projects.html +499 -28
  78. package/modules/public-landing/public/projects.probes.json +2 -2
  79. package/modules/public-landing/public/projects.states.json +6 -3
  80. package/modules/ui-design/kit/serve.js +50 -0
  81. package/package-lock.json +2 -2
  82. package/package.json +1 -1
  83. package/release-notes.json +12 -0
  84. package/scripts/gds/fitness.js +4 -0
  85. package/scripts/gds/run-unit-tests.js +5 -0
  86. package/src/bongos/db.js +44 -6
  87. package/src/bongos/role-stats.js +155 -0
  88. package/src/bongos/routes/builders.js +15 -2
  89. package/src/bongos/routes/me.js +11 -3
  90. package/src/bongos/routes.js +4 -1
  91. package/src/bongos/serve-internal.js +6 -0
  92. package/src/branding.js +26 -1
  93. package/src/hall-look.js +99 -0
  94. package/src/module-api.js +1 -1
  95. package/src/night-tint.js +26 -0
  96. package/tests/copy_desk_page_reads.mjs +1 -1
  97. package/tests/currency_label.mjs +7 -4
  98. package/tests/fixtures/page-ledger.mjs +1 -1
  99. package/tests/hall_tokens.mjs +10 -1
  100. package/tests/hall_tweak_editor.mjs +1 -1
  101. package/tests/look_night_tint.mjs +169 -0
  102. package/tests/main_discipline.mjs +81 -0
  103. package/tests/nav_permission_atoms.mjs +1 -1
  104. package/tests/profile_role_stats.mjs +416 -0
  105. package/tests/projects_hub.mjs +5 -4
  106. package/tests/projects_hub_app_status.mjs +1 -1
  107. package/tests/projects_hub_app_step.mjs +8 -7
  108. package/tests/projects_hub_look.mjs +255 -0
  109. package/tests/projects_hub_module_picker.mjs +2 -2
  110. package/tests/provision_settings_apply.mjs +12 -4
  111. package/tests/provisioning_look.mjs +376 -0
  112. package/tests/provisioning_settings.mjs +10 -7
  113. package/tests/provisioning_settings_apply.mjs +4 -4
  114. package/tests/provisioning_settings_env.mjs +4 -0
  115. package/tests/role_stats_db.mjs +237 -0
  116. package/tests/tweak_replaces_artist_review.mjs +1 -1
  117. package/tests/wizard_draft_resume.mjs +2 -2
  118. package/tests/wizard_front_door.mjs +2 -2
  119. package/tests/wizard_intent_resume.mjs +16 -15
@@ -0,0 +1,237 @@
1
+ // tests/role_stats_db.mjs — the two new money reads of WA6.RP03 (task 1004433)
2
+ // against a REAL Postgres: economy's credits-by-craft split and the lifecycle's
3
+ // per-person earnings fragment.
4
+ //
5
+ // WHY A REAL DATABASE. Both are WHERE-clause and JOIN questions — which ledger
6
+ // rows a LIKE pattern reaches (the escaped underscore in the artist prefix), how
7
+ // an empty session_records.task_ids array fans out through a LATERAL unnest,
8
+ // and what numeric ROUND does to a half credit. A stubbed client answers
9
+ // exactly the assumption under test (the session_earnings_mirror_db argument),
10
+ // so tests/profile_role_stats.mjs covers the pure halves and this covers the SQL.
11
+ //
12
+ // It SELF-SKIPS (exit 0) without a reachable Postgres and is listed in
13
+ // run-unit-tests.js's INTEGRATION set. Every test runs inside one transaction
14
+ // under a savepoint and the whole run is rolled back: it writes nothing that
15
+ // survives, so it is safe against a dev database.
16
+ //
17
+ // Run: DATABASE_URL=postgres://... node tests/role_stats_db.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const { Pool } = require('pg');
24
+ const byCraft = require('../modules/economy/credits-by-craft.js');
25
+ const { creditsEarnedBySql, CREDITS_AWARDED_SUBQUERY } = require('../modules/lifecycle/db-tasks.js');
26
+
27
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
28
+
29
+ let passed = 0;
30
+ let failed = 0;
31
+ let client;
32
+ async function test(name, fn) {
33
+ await client.query('SAVEPOINT t');
34
+ try {
35
+ await fn();
36
+ passed++; console.log(` ok ${name}`);
37
+ await client.query('RELEASE SAVEPOINT t');
38
+ } catch (err) {
39
+ failed++; console.error(` FAIL ${name}\n ${err.message}`);
40
+ await client.query('ROLLBACK TO SAVEPOINT t');
41
+ }
42
+ }
43
+
44
+ // The schema-driven row builder session_earnings_mirror_db uses: fill every
45
+ // NOT NULL column with no default, so the fixture survives a later migration
46
+ // adding one.
47
+ const schemaCache = new Map();
48
+ async function columnsOf(table) {
49
+ if (schemaCache.has(table)) return schemaCache.get(table);
50
+ const { rows } = await client.query(
51
+ `SELECT column_name, data_type, is_nullable, column_default, is_identity
52
+ FROM information_schema.columns
53
+ WHERE table_schema = 'public' AND table_name = $1`,
54
+ [table]
55
+ );
56
+ schemaCache.set(table, rows);
57
+ return rows;
58
+ }
59
+ function placeholderFor(dataType, columnName) {
60
+ switch (dataType) {
61
+ case 'integer': case 'bigint': case 'smallint':
62
+ case 'numeric': case 'real': case 'double precision':
63
+ return 0;
64
+ case 'boolean': return false;
65
+ case 'timestamp with time zone': case 'timestamp without time zone': case 'date':
66
+ return new Date();
67
+ case 'json': case 'jsonb': return {};
68
+ case 'ARRAY': return [];
69
+ default: return `rp03-${columnName}`;
70
+ }
71
+ }
72
+ async function insertRow(table, explicit = {}) {
73
+ const cols = await columnsOf(table);
74
+ if (cols.length === 0) throw new Error(`table ${table} not found — is this database migrated?`);
75
+ const values = { ...explicit };
76
+ for (const c of cols) {
77
+ if (c.column_name in values) continue;
78
+ if (c.is_nullable === 'YES' || c.column_default !== null || c.is_identity === 'YES') continue;
79
+ values[c.column_name] = placeholderFor(c.data_type, c.column_name);
80
+ }
81
+ const names = Object.keys(values);
82
+ const { rows } = await client.query(
83
+ `INSERT INTO ${table} (${names.map((n) => `"${n}"`).join(', ')})
84
+ VALUES (${names.map((_, i) => `$${i + 1}`).join(', ')}) RETURNING *`,
85
+ names.map((n) => values[n])
86
+ );
87
+ return rows[0];
88
+ }
89
+ function credit(builderId, { taskId = null, delta, reason, sessionId = null, basis = null }) {
90
+ return client.query(
91
+ `INSERT INTO credit_log (builder_id, task_id, delta, reason, description, session_id, reward_basis)
92
+ VALUES ($1, $2, $3, $4, 'rp03 test', $5, COALESCE($6::int, 0))`,
93
+ [builderId, taskId, delta, reason, sessionId, basis]
94
+ );
95
+ }
96
+
97
+ // One builder with a row in every branch of the classification, and a second
98
+ // builder holding a row on the same task, who must never leak into the first.
99
+ async function fixture() {
100
+ const tag = `rp03-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
101
+ const b = await insertRow('builders', { github_id: `${tag}-gh`, github_login: tag, display_name: tag, total_credits: 0 });
102
+ const other = await insertRow('builders', { github_id: `${tag}-gh2`, github_login: `${tag}-2`, display_name: `${tag}-2`, total_credits: 0 });
103
+ const version = await insertRow('versions', { id: `${tag}-v`, status: 'building' });
104
+ const mk = (title, discipline) => insertRow('tasks', { title: `${tag} ${title}`, status: 'shipped', version_id: version.id, discipline });
105
+ const eng = await mk('eng', 'engineer');
106
+ const page = await mk('page', 'artist');
107
+ const ui = await mk('ui', 'ui');
108
+ const sid = `${tag}-session`;
109
+ await insertRow('session_records', { session_id: sid, builder_id: b.id, task_ids: [eng.id, ui.id], drachmae_earned: 0, total_tokens: 1 });
110
+ const lone = `${tag}-lone`;
111
+ await insertRow('session_records', { session_id: lone, builder_id: b.id, task_ids: [], drachmae_earned: 0, total_tokens: 1 });
112
+
113
+ await credit(b.id, { taskId: eng.id, delta: 10, reason: 'task.shipped' });
114
+ await credit(b.id, { taskId: page.id, delta: 30, reason: `tweak.page_approved:${page.id}` });
115
+ await credit(b.id, { taskId: eng.id, delta: 4, reason: `idea.credit.task_ship:5:${eng.id}` });
116
+ await credit(b.id, { delta: 5, reason: 'idea.credit.full_pass:5' });
117
+ await credit(b.id, { taskId: eng.id, delta: 2, reason: 'achievement.first_ship' });
118
+ await credit(b.id, { delta: 9, reason: 'session.token_reward', sessionId: sid, basis: 9 });
119
+ await credit(b.id, { delta: 3, reason: 'session.token_reward', sessionId: lone, basis: 3 });
120
+ // A LIKE-wildcard twin: an unescaped underscore would let this row read as an
121
+ // approved page. It is not one, so it must fall to `other`.
122
+ await credit(b.id, { delta: 7, reason: 'tweak.pageXapproved:1' });
123
+ await credit(other.id, { taskId: eng.id, delta: 50, reason: 'task.shipped' });
124
+ return { b, other, eng, page, ui };
125
+ }
126
+
127
+ async function main() {
128
+ try {
129
+ client = await pool.connect();
130
+ } catch {
131
+ console.log('role_stats_db: no Postgres (DATABASE_URL unset/unreachable) — skipping');
132
+ await pool.end().catch(() => {});
133
+ return;
134
+ }
135
+ await client.query('BEGIN');
136
+ try {
137
+ const f = await fixture();
138
+
139
+ await test('credits by craft: every reason lands in its craft and the parts sum to the ledger exactly', async () => {
140
+ const map = await byCraft.creditsByCraft([f.b.id], { client });
141
+ const row = map.get(String(f.b.id));
142
+ // engineer: 10 shipped + half of the 9 session + the 3 lone session = 17.5
143
+ // artist: 30 page + the ui half of the session (4.5) = 34.5
144
+ // ideator: 4 stream 1 + 5 full pass = 9
145
+ // other: 2 achievement + 7 wildcard twin = 9
146
+ assert.equal(row.ideator, 9);
147
+ assert.equal(row.other, 9);
148
+ assert.equal(row.total, 70);
149
+ assert.equal(row.engineer + row.artist + row.ideator + row.other, row.total, 'the parts are exact to the whole');
150
+ // 17.5 and 34.5 floor to 51 of 52; the one left over goes to the larger
151
+ // remainder, and on a tie to the earlier craft, engineer.
152
+ assert.equal(row.engineer, 18);
153
+ assert.equal(row.artist, 34);
154
+ });
155
+
156
+ await test('credits by craft: another builder on the same task is never counted', async () => {
157
+ const map = await byCraft.creditsByCraft([f.other.id], { client });
158
+ assert.equal(map.get(String(f.other.id)).engineer, 50);
159
+ assert.equal(map.get(String(f.other.id)).total, 50);
160
+ });
161
+
162
+ await test('person earnings: the per-task rows plus an even share of the session, this builder only', async () => {
163
+ const { rows } = await client.query(
164
+ `SELECT t.id, ${creditsEarnedBySql('$1')} AS c FROM tasks t WHERE t.id = ANY($2::bigint[]) ORDER BY t.id`,
165
+ [f.b.id, [f.eng.id, f.page.id, f.ui.id]]
166
+ );
167
+ const c = Object.fromEntries(rows.map((r) => [String(r.id), Number(r.c)]));
168
+ // eng: 10 shipped + 4 idea + round(9/2 = 4.5) = 19 (the achievement is not pay for the task)
169
+ assert.equal(c[String(f.eng.id)], 19);
170
+ assert.equal(c[String(f.page.id)], 30, 'the approved page is the artist pay for it');
171
+ assert.equal(c[String(f.ui.id)], 5, 'the other half of the session');
172
+ const { rows: o } = await client.query(`SELECT ${creditsEarnedBySql('$1')} AS c FROM tasks t WHERE t.id = $2`, [f.other.id, f.eng.id]);
173
+ assert.equal(Number(o[0].c), 50, 'the second builder reads their own pay, not the first one');
174
+ });
175
+
176
+ await test('main role: set, kept while still chosen, cleared when its craft is dropped, cleared on purpose', async () => {
177
+ const { UPDATE_BUILDER_DISCIPLINES_SQL: sql } = require('../src/bongos/db.js');
178
+ const run = async (list, setMain, main) =>
179
+ (await client.query(sql, [f.b.id, list, setMain, main])).rows[0].main_discipline;
180
+ assert.equal(await run(['engineer', 'artist'], true, 'artist'), 'artist', 'chosen');
181
+ assert.equal(await run(['engineer', 'artist', 'ideator'], false, null), 'artist', 'not mentioned, still one of the crafts: kept');
182
+ assert.equal(await run(['engineer'], false, null), null, 'the craft was dropped: a main role they no longer practise is cleared');
183
+ assert.equal(await run(['engineer'], true, 'engineer'), 'engineer');
184
+ assert.equal(await run(['engineer'], true, null), null, 'cleared on purpose');
185
+ });
186
+
187
+ await test('artist page read: only rounds whose LATEST web claim is theirs, never a CLI claim, never an ordinary task', async () => {
188
+ const { listPageTweakTasks } = require('../modules/lifecycle/page-tweak-reads.js');
189
+ const tag = `rp03p-${Date.now()}`;
190
+ const version = await insertRow('versions', { id: `${tag}-v`, status: 'building' });
191
+ const mk = (title, extra) => insertRow('tasks', { title: `${tag} ${title}`, status: 'shipped', version_id: version.id, discipline: 'artist', ...extra });
192
+ const mine = await mk('mine', { source: 'page-tweak', source_ref: `page-tweak/landing:${tag}a/r1` });
193
+ const takenOver = await mk('taken over', { source: 'page-tweak', source_ref: `page-tweak/landing:${tag}b/r1` });
194
+ const cliOnly = await mk('cli only', { source: 'page-tweak', source_ref: `page-tweak/landing:${tag}c/r1` });
195
+ const ordinary = await mk('ordinary', {});
196
+ const t0 = Date.now() - 60 * 60 * 1000;
197
+ const claim = (taskId, builderId, minutes, worktree = null) => insertRow('claims', {
198
+ task_id: taskId, builder_id: builderId, worktree_name: worktree, creator_session_id: null,
199
+ claimed_at: new Date(t0 + minutes * 60 * 1000), released_at: new Date(t0 + (minutes + 1) * 60 * 1000),
200
+ });
201
+ await claim(mine.id, f.b.id, 0);
202
+ await claim(takenOver.id, f.b.id, 0);
203
+ await claim(takenOver.id, f.other.id, 5); // the later web claim is someone else's
204
+ await claim(cliOnly.id, f.b.id, 0, `${tag}-wt`); // a worktree claim is an applier, not an artist
205
+ await claim(ordinary.id, f.b.id, 0);
206
+
207
+ const scoped = await listPageTweakTasks({ artistId: f.b.id, client });
208
+ assert.deepEqual(scoped.map((r) => String(r.id)), [String(mine.id)]);
209
+ assert.equal(String(scoped[0].artist_id), String(f.b.id));
210
+
211
+ const theirs = await listPageTweakTasks({ artistId: f.other.id, client });
212
+ assert.deepEqual(theirs.map((r) => String(r.id)), [String(takenOver.id)]);
213
+
214
+ const all = (await listPageTweakTasks({ client })).map((r) => String(r.id));
215
+ for (const id of [mine.id, takenOver.id, cliOnly.id]) assert.ok(all.includes(String(id)), 'the unscoped read is unchanged: every page round');
216
+ assert.ok(!all.includes(String(ordinary.id)));
217
+ });
218
+
219
+ await test('task payout: the approved page and the idea row now count, the achievement still does not', async () => {
220
+ const { rows } = await client.query(
221
+ `SELECT t.id, ${CREDITS_AWARDED_SUBQUERY} AS c FROM tasks t WHERE t.id = ANY($1::bigint[])`,
222
+ [[f.eng.id, f.page.id]]
223
+ );
224
+ const c = Object.fromEntries(rows.map((r) => [String(r.id), Number(r.c)]));
225
+ assert.equal(c[String(f.page.id)], 30);
226
+ assert.equal(c[String(f.eng.id)], 10 + 4 + 50, 'both shippers and the idea, no achievement, no session');
227
+ });
228
+ } finally {
229
+ await client.query('ROLLBACK').catch(() => {});
230
+ client.release();
231
+ await pool.end().catch(() => {});
232
+ }
233
+ console.log(`\n${passed} passed, ${failed} failed`);
234
+ if (failed) process.exit(1);
235
+ }
236
+
237
+ main().catch((err) => { console.error(err); process.exit(1); });
@@ -51,7 +51,7 @@ api.pool = {
51
51
  async query(sql, params = []) {
52
52
  const q = flat(sql);
53
53
  seen.push({ q, params });
54
- if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) return { rows: F.TASKS };
54
+ if (/FROM pt JOIN tasks t ON t\.id = pt\.id LEFT JOIN task_grades g/.test(q)) return { rows: F.TASKS };
55
55
  if (/t\.touches && \$1::text\[\]/.test(q)) {
56
56
  // The real query's predicate, applied to the one ship: shipped after the
57
57
  // page's last round, touching one of the page's files.
@@ -90,9 +90,9 @@ test('post-auth: the real step is rendered', () => {
90
90
 
91
91
  test('a draft already at step 1 is unchanged, and an out-of-range step is not resurrected', () => {
92
92
  assert.equal(runStep({ authKnown: false, draftStep: 1 }).persisted.step, 1);
93
- // 11 is the done panel (9 before task 1004417's front door, 8 before task 1004351), reached by its own branch
93
+ // 12 is the done panel (11 before task 1004421's Look step, 9 before task 1004417's front door, 8 before task 1004351), reached by its own branch
94
94
  // above this slice; 0 is the gate. Neither is a resumable wizard position, so the placeholder is allowed to stand.
95
- for (const bad of [0, 11, 99]) {
95
+ for (const bad of [0, 12, 99]) {
96
96
  assert.equal(runStep({ authKnown: false, draftStep: bad }).persisted.step, 1, `step ${bad} is not resumed`);
97
97
  }
98
98
  });
@@ -53,7 +53,7 @@ test('the front door stands between sign-in and the name step: gate 0, terms 1,
53
53
 
54
54
  test('the rail is the numbered steps\' — it steps aside on both front-door screens, statically too', () => {
55
55
  const beats = [...HUB.matchAll(/<li[^>]*data-step="(\d+)"/g)].map((m) => Number(m[1]));
56
- assert.deepEqual(beats, [3, 4, 5, 6, 7, 8, 9, 10], 'the rail numbers name…create, never the front door');
56
+ assert.deepEqual(beats, [3, 4, 5, 6, 7, 8, 9, 10, 11], 'the rail numbers name…create, never the front door');
57
57
  assert.match(HUB, /<div class="card wizFront">/, 'a no-script visitor on the static first screen sees no rail');
58
58
  assert.match(STYLE, /#view-new \.card\.wizFront \.steps\{display:none;\}/);
59
59
  });
@@ -250,7 +250,7 @@ test('the create carries the tick, and is never filed without it', () => {
250
250
  test('the tick is part of the draft and of the reset, and the draft key moved with the renumbering', () => {
251
251
  assert.match(SCRIPT, /var state = \{ step: 0,[^}]*termsAck: false \};/, 'the initial state declares it');
252
252
  assert.match(SCRIPT, /state = \{ step: 1,[^}]*termsAck: false \};/, 'and "start another project" clears it — each project records its own');
253
- assert.match(SCRIPT, /var DRAFT_KEY = 'cb-create-draft-v6';/, 'a draft saved under the old numbering is ignored, not replayed onto the wrong screen');
253
+ assert.match(SCRIPT, /var DRAFT_KEY = 'cb-create-draft-v7';/, 'a draft saved under the old numbering is ignored, not replayed onto the wrong screen');
254
254
  });
255
255
 
256
256
  // ── the look-before-you-ship kit walks it ─────────────────────────────────────
@@ -15,12 +15,12 @@
15
15
  // ruled out the task's other option (drop the record at a terminal state).
16
16
  //
17
17
  // The reset fires only when there is a SECOND DRAFT to lose, and the draft's own step is
18
- // what says so. A landed create clears DRAFT_KEY — but show(11) then persists the done
18
+ // what says so. A landed create clears DRAFT_KEY — but show(12) then persists the done
19
19
  // panel's position straight back into it, so a plain "is there a draft?" test is always
20
20
  // yes and would skip the reset forever, leaving the finished project's name sitting in
21
21
  // the fresh form. (That is exactly what the first cut of this change shipped into a
22
22
  // render: "still watching Mercury" above a name field still reading "Mercury".) Only
23
- // steps 1-10 are wizard steps (1-8 before task 1004417's front door took 1 and 2);
23
+ // steps 1-11 are wizard steps (1-10 before task 1004421's Look step, 1-8 before task 1004417's front door took 1 and 2);
24
24
  // anything else is the done panel's own bookkeeping.
25
25
  //
26
26
  // Harness: there is no jsdom in this repo, so this follows tests/wizard_draft_resume.mjs
@@ -58,8 +58,8 @@ test('both slices are still findable (a rename must fail loudly, not silently pa
58
58
  assert.ok(offerSlice, 'paintResumeOffer() was not found in projects.html');
59
59
  assert.match(enterSlice, /if \(focus && !resumeIntent\) \{/, 'the intent branch is part of the slice');
60
60
  assert.match(enterSlice, /startProgress\(created\.id\);/, 'so is the resume branch it guards');
61
- assert.match(enterSlice, /draft\.step >= 1 && draft\.step <= 10/,
62
- 'the STORED draft\'s step decides, not state.step — state.step is 11 for the whole life of a finished standup');
61
+ assert.match(enterSlice, /draft\.step >= 1 && draft\.step <= 11/,
62
+ 'the STORED draft\'s step decides, not state.step — state.step is 12 for the whole life of a finished standup');
63
63
  assert.match(SCRIPT, /if \(created && created\.id\) \{ enterCreated\(created, !!focus\); return; \}/,
64
64
  'enter() still hands the created record straight to it');
65
65
  });
@@ -73,7 +73,7 @@ function runEnter({ focus, resumeIntent = false, draft = null, record }) {
73
73
  const calls = { show: [], resets: 0, offer: [], done: 0, progress: [], polls: 0, focused: 0 };
74
74
  // state.step is the DONE step for the whole life of a finished standup — the value
75
75
  // a draft-blind reading would have trusted
76
- const state = { step: draft ? draft.step : 11, name: 'Mercury', slug: 'mercury', domain: '', mode: 'greenfield', noAddress: false, modules: null };
76
+ const state = { step: draft ? draft.step : 12, name: 'Mercury', slug: 'mercury', domain: '', mode: 'greenfield', noAddress: false, modules: null };
77
77
  const sandbox = {
78
78
  record, focus, resumeIntent, state,
79
79
  DRAFT_KEY: 'draft',
@@ -99,7 +99,7 @@ const REC = { id: 'inst-1', name: 'Acme', slug: 'acme', domain: 'acme.example',
99
99
 
100
100
  test('a reload / Back / identity settle (focus false) still resumes the done panel, watch and all', () => {
101
101
  const { calls, state } = runEnter({ focus: false, record: REC });
102
- assert.deepEqual(calls.show, [[11, false]], 'the done panel, and no focus steal on a non-deliberate arrival');
102
+ assert.deepEqual(calls.show, [[12, false]], 'the done panel, and no focus steal on a non-deliberate arrival');
103
103
  assert.equal(calls.resets, 0, 'nothing is reset — this is the same standup continuing');
104
104
  assert.equal(calls.done, 1, 'the checklist is re-rendered');
105
105
  assert.deepEqual(calls.progress, ['inst-1'], 'the progress watch is restarted from the record');
@@ -127,7 +127,7 @@ test('…and the standup stays one click away, named, on the resume link', () =>
127
127
 
128
128
  test('the resume link gets the done panel back — the record was never cleared', () => {
129
129
  const { calls, sandbox } = runEnter({ focus: true, resumeIntent: true, record: REC });
130
- assert.deepEqual(calls.show, [[11, true]], 'a deliberate resume moves focus to the done panel');
130
+ assert.deepEqual(calls.show, [[12, true]], 'a deliberate resume moves focus to the done panel');
131
131
  assert.deepEqual(calls.progress, ['inst-1'], 'the watch comes back with it');
132
132
  assert.equal(sandbox.resumeIntent, false, 'the flag is ONE-SHOT — the next plain nav click must mean "new" again');
133
133
  assert.deepEqual(calls.offer, [null], 'and the offer comes down behind it');
@@ -145,20 +145,21 @@ test('a second draft already under way is NOT wiped by navigating back to the wi
145
145
  });
146
146
 
147
147
  test('the done panel\'s own saved step is NOT a second draft — it must not block the reset', () => {
148
- // Caught in a render, not in a unit test: show(11) calls saveDraft(), so DRAFT_KEY is
148
+ // Caught in a render, not in a unit test: show(12) calls saveDraft(), so DRAFT_KEY is
149
149
  // non-empty again the moment a create lands. Reading "is there a draft?" left the
150
150
  // finished project's name in the fresh form, under a line offering to resume it.
151
- const { calls, state } = runEnter({ focus: true, record: REC, draft: { step: 11, name: 'Mercury' } });
151
+ const { calls, state } = runEnter({ focus: true, record: REC, draft: { step: 12, name: 'Mercury' } });
152
152
  assert.equal(calls.resets, 1, 'the done step is bookkeeping, not a project being typed');
153
153
  assert.deepEqual(calls.show, [[1, false]], 'out-of-range steps land on step 1, the front door');
154
154
  assert.equal(state.name, '', 'and the finished project\'s name does not survive into it');
155
155
  assert.equal(calls.done, 0, 'never back onto the done panel');
156
156
  });
157
157
 
158
- test('a draft on the app step or the review is live too — the last two numbered steps (task 1004351)', () => {
159
- // The app step moved the review from 7 to 8, and the front door (task 1004417) to 10. A
160
- // guard left behind would reset a second project sitting on its review, silently wiping it.
161
- for (const step of [9, 10]) {
158
+ test('a draft on the app step, the Look step or the review is live too — the last three numbered steps (task 1004351)', () => {
159
+ // The app step moved the review from 7 to 8, the front door (task 1004417) to 10 and the
160
+ // Look step (task 1004421) to 11. A guard left behind would reset a second project
161
+ // sitting on its review, silently wiping it.
162
+ for (const step of [9, 10, 11]) {
162
163
  const { calls, state } = runEnter({ focus: true, record: REC, draft: { step } });
163
164
  assert.equal(calls.resets, 0, `step ${step} is a live draft`);
164
165
  assert.deepEqual(calls.show, [[step, true]]);
@@ -174,8 +175,8 @@ test('a draft on the front door is live too — a founder part-way through it ke
174
175
  }
175
176
  });
176
177
 
177
- test('a draft whose step is missing or junk resets too — only 1-10 preserves anything', () => {
178
- for (const draft of [{ step: 0 }, { step: 11 }, { name: 'half-typed' }]) {
178
+ test('a draft whose step is missing or junk resets too — only 1-11 preserves anything', () => {
179
+ for (const draft of [{ step: 0 }, { step: 12 }, { name: 'half-typed' }]) {
179
180
  const { calls } = runEnter({ focus: true, record: REC, draft });
180
181
  assert.equal(calls.resets, 1, `step ${JSON.stringify(draft.step)} is not a wizard step`);
181
182
  assert.deepEqual(calls.show, [[1, false]]);