@bongos/core 1.20.59 → 1.20.61

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 (96) hide show
  1. package/.bongos-core.json +151 -91
  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 +14 -1
  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 +324 -3
  10. package/docs/api-reference.md +8 -3
  11. package/docs/copy-inventory.md +343 -333
  12. package/docs/copy-registry.json +527 -433
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-inventory.json +4 -1
  15. package/docs/page-readings.json +758 -707
  16. package/modules/autonomy/db.js +13 -6
  17. package/modules/autonomy/migrations/autonomy_004_runner_identity.sql +30 -0
  18. package/modules/autonomy/routes/autonomy.js +16 -0
  19. package/modules/autonomy/runner-health.js +66 -5
  20. package/modules/hall-ui/public/atlas.html +1 -1
  21. package/modules/hall-ui/public/blockers.html +1 -1
  22. package/modules/hall-ui/public/board-room.html +1 -1
  23. package/modules/hall-ui/public/collab.html +1 -1
  24. package/modules/hall-ui/public/commands.html +1 -1
  25. package/modules/hall-ui/public/copy-desk.html +1 -1
  26. package/modules/hall-ui/public/deploy.html +1 -1
  27. package/modules/hall-ui/public/diagrams.html +1 -1
  28. package/modules/hall-ui/public/drachmae.html +1 -1
  29. package/modules/hall-ui/public/fleet.html +1 -1
  30. package/modules/hall-ui/public/gate.html +1 -1
  31. package/modules/hall-ui/public/gate.js +24 -8
  32. package/modules/hall-ui/public/goal-map.html +1 -1
  33. package/modules/hall-ui/public/goals.html +1 -1
  34. package/modules/hall-ui/public/government.html +1 -1
  35. package/modules/hall-ui/public/idea.html +1 -1
  36. package/modules/hall-ui/public/ideas.html +1 -1
  37. package/modules/hall-ui/public/index.html +1 -1
  38. package/modules/hall-ui/public/modules.html +1 -1
  39. package/modules/hall-ui/public/primer.html +1 -1
  40. package/modules/hall-ui/public/profile.html +1 -1
  41. package/modules/hall-ui/public/project-settings.html +1 -1
  42. package/modules/hall-ui/public/ranks.html +1 -1
  43. package/modules/hall-ui/public/roadmap.html +1 -1
  44. package/modules/hall-ui/public/roster.html +1 -1
  45. package/modules/hall-ui/public/sessions.html +1 -1
  46. package/modules/hall-ui/public/settings.html +1 -1
  47. package/modules/hall-ui/public/shell.js +5 -1
  48. package/modules/hall-ui/public/studio.html +1 -1
  49. package/modules/hall-ui/public/style.css +8 -0
  50. package/modules/hall-ui/public/task.html +1 -1
  51. package/modules/hall-ui/public/thinking.html +1 -1
  52. package/modules/hall-ui/public/tweak-editor.html +1 -1
  53. package/modules/hall-ui/public/watch.html +1 -1
  54. package/modules/hall-ui/public/work.html +1 -1
  55. package/modules/provisioning/look.js +169 -0
  56. package/modules/provisioning/migrations/provisioning_034_project_logo.sql +35 -0
  57. package/modules/provisioning/module.json +2 -1
  58. package/modules/provisioning/provisioning.js +4 -3
  59. package/modules/provisioning/routes/look.js +204 -0
  60. package/modules/provisioning/routes/provisioning.js +5 -59
  61. package/modules/provisioning/settings-push.js +76 -0
  62. package/modules/public-landing/public/assets/cosmos.css +1 -1
  63. package/modules/public-landing/public/projects.html +499 -28
  64. package/modules/public-landing/public/projects.probes.json +2 -2
  65. package/modules/public-landing/public/projects.states.json +6 -3
  66. package/modules/ui-design/kit/serve.js +50 -0
  67. package/package-lock.json +2 -2
  68. package/package.json +1 -1
  69. package/release-notes.json +12 -0
  70. package/scripts/gds/autobongos-run.js +43 -2
  71. package/scripts/gds/fitness.js +4 -0
  72. package/src/bongos/routes.js +4 -1
  73. package/src/bongos/serve-internal.js +6 -0
  74. package/src/branding.js +26 -1
  75. package/src/hall-look.js +99 -0
  76. package/src/module-api.js +1 -1
  77. package/src/night-tint.js +26 -0
  78. package/tests/autonomy_runner_identity.mjs +215 -0
  79. package/tests/currency_label.mjs +7 -4
  80. package/tests/hall_tokens.mjs +10 -1
  81. package/tests/hall_tweak_editor.mjs +1 -1
  82. package/tests/look_night_tint.mjs +169 -0
  83. package/tests/nav_permission_atoms.mjs +1 -1
  84. package/tests/projects_hub.mjs +5 -4
  85. package/tests/projects_hub_app_status.mjs +1 -1
  86. package/tests/projects_hub_app_step.mjs +8 -7
  87. package/tests/projects_hub_look.mjs +255 -0
  88. package/tests/projects_hub_module_picker.mjs +2 -2
  89. package/tests/provision_settings_apply.mjs +12 -4
  90. package/tests/provisioning_look.mjs +376 -0
  91. package/tests/provisioning_settings.mjs +10 -7
  92. package/tests/provisioning_settings_apply.mjs +4 -4
  93. package/tests/provisioning_settings_env.mjs +4 -0
  94. package/tests/wizard_draft_resume.mjs +2 -2
  95. package/tests/wizard_front_door.mjs +2 -2
  96. package/tests/wizard_intent_resume.mjs +16 -15
@@ -0,0 +1,376 @@
1
+ // tests/provisioning_look.mjs — a project's look, night tint and logo, platform side
2
+ // (task 1004421, BV2.PS06; ADR 0358).
3
+ //
4
+ // The load-bearing assertions:
5
+ // • the vocabulary IS the style library plus `own` and `custom` — a look added to
6
+ // modules/ui-design/styles/ without joining the list (or the reverse) fails here;
7
+ // • the accent nudge ADJUSTS, never refuses: every colour round the hue wheel comes
8
+ // back readable in both modes, a readable one comes back untouched, and a moved
9
+ // one keeps its hue;
10
+ // • the wizard's copy of that nudge IS the server's, character for character;
11
+ // • a logo is screened by its BYTES — an SVG, a renamed file or an oversized one is
12
+ // refused before anything is stored;
13
+ // • the routes are the owner's (404 for anyone else), store the ADJUSTED accent and
14
+ // say so, refuse a custom look with no accent, and push like PATCH …/settings;
15
+ // • the public logo read serves only a content-addressed name, as an image only.
16
+ //
17
+ // DB-free: data access is stubbed on the cached module objects (the
18
+ // provisioning_repo_private_route.mjs pattern), over a real mounted router.
19
+ //
20
+ // Run: node tests/provisioning_look.mjs
21
+
22
+ import { strict as assert } from 'node:assert';
23
+ import { createRequire } from 'node:module';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import http from 'node:http';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const provisioning = require('../modules/provisioning/provisioning.js');
32
+ const look = require('../modules/provisioning/look.js');
33
+
34
+ let passed = 0, failed = 0;
35
+ async function ta(name, fn) {
36
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
37
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
38
+ }
39
+
40
+ // ── contrast, restated independently of look.js so the nudge is checked, not echoed ──
41
+ const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
42
+ const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
43
+ const ratio = (a, b) => { const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); };
44
+ const rgb = (hex) => { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
45
+ const mix = (a, p, b) => a.map((v, i) => v * p + b[i] * (1 - p));
46
+ function readsEverywhere(hex) {
47
+ const c = rgb(hex);
48
+ const fails = [];
49
+ if (ratio([42, 19, 5], c) < 4.5) fails.push('dark ink on the accent fill');
50
+ for (const [name, ground] of [['paper', [237, 236, 238]], ['card', [251, 250, 252]]]) {
51
+ if (ratio(mix(c, 0.45, [36, 14, 0]), ground) < 4.5) fails.push(`light accent ink on ${name}`);
52
+ }
53
+ for (const [name, ground] of [['black', [0, 0, 0]], ['dark card', [16, 16, 18]],
54
+ ['tinted night', mix(c, 0.06, [0, 0, 0])], ['tinted card', mix(c, 0.05, [16, 16, 18])]]) {
55
+ if (ratio(c, ground) < 4.5) fails.push(`accent on ${name}`);
56
+ }
57
+ const info = mix([91, 111, 208], 0.8, [255, 255, 255]);
58
+ if (ratio(info, mix(info, 0.14, mix(c, 0.05, [16, 16, 18]))) < 4.5) fails.push('info chip on the tinted card');
59
+ return fails;
60
+ }
61
+ const hueOf = (hex) => {
62
+ const [r, g, b] = rgb(hex).map((v) => v / 255);
63
+ const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
64
+ if (!d) return null;
65
+ const h = max === r ? ((g - b) / d + 6) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
66
+ return h * 60;
67
+ };
68
+ const hslHex = (h, s, l) => {
69
+ const a = s * Math.min(l, 1 - l), k = (n) => (n + h / 30) % 12;
70
+ const f = (n) => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))));
71
+ return '#' + [f(0), f(8), f(4)].map((x) => x.toString(16).padStart(2, '0')).join('');
72
+ };
73
+
74
+ console.log('\nThe vocabulary is the style library:');
75
+
76
+ await ta('LIBRARY_LOOKS is exactly the style directories, and the setting adds own + custom around them', async () => {
77
+ const dirs = fs.readdirSync(path.join(ROOT, 'modules', 'ui-design', 'styles'))
78
+ .filter((d) => fs.existsSync(path.join(ROOT, 'modules', 'ui-design', 'styles', d, 'pack.json'))).sort();
79
+ assert.deepEqual([...look.LIBRARY_LOOKS].sort(), dirs);
80
+ assert.deepEqual(look.LOOK_SETTINGS_VOCAB.look, ['own', ...look.LIBRARY_LOOKS, 'custom']);
81
+ assert.deepEqual(provisioning.SETTINGS_VOCAB.look, look.LOOK_SETTINGS_VOCAB.look, 'the look is a policy setting');
82
+ assert.deepEqual(provisioning.SETTINGS_VOCAB.night_tint, ['on', 'off']);
83
+ });
84
+
85
+ await ta('the defaults change nothing: own pack, and a tint that only a look can switch on', async () => {
86
+ const s = provisioning.effectiveSettings({ settings: {} });
87
+ assert.equal(s.look, 'own');
88
+ assert.equal(s.night_tint, 'on');
89
+ const vars = provisioning.settingsEnvVars({ settings: {} }, 'X');
90
+ assert.equal(vars.X_LOOK, 'own');
91
+ assert.equal(vars.X_LOOK_ACCENT, '');
92
+ assert.equal(vars.X_LOGO_URL, '');
93
+ });
94
+
95
+ await ta('the companions carry only a well-formed value — a stored accent or address that is not one goes out empty', async () => {
96
+ const good = provisioning.settingsEnvVars({ settings: { look: 'custom', look_accent: '#B48CF2', logo_url: 'https://x.test/api/bongos/provisioning/logos/a.png' } }, 'X');
97
+ assert.equal(good.X_LOOK_ACCENT, '#b48cf2');
98
+ assert.equal(good.X_LOGO_URL, 'https://x.test/api/bongos/provisioning/logos/a.png');
99
+ const bad = provisioning.settingsEnvVars({ settings: { look_accent: 'red', logo_url: 'javascript:alert(1)' } }, 'X');
100
+ assert.equal(bad.X_LOOK_ACCENT, '');
101
+ assert.equal(bad.X_LOGO_URL, '');
102
+ const spaced = provisioning.settingsEnvVars({ settings: { logo_url: 'https://x.test/a b.png' } }, 'X');
103
+ assert.equal(spaced.X_LOGO_URL, '', 'a space would split the env line');
104
+ });
105
+
106
+ console.log('\nThe accent nudge adjusts, never refuses:');
107
+
108
+ await ta('a colour that already reads in both modes comes back untouched', async () => {
109
+ for (const hex of ['#fb9569', '#b48cf2', '#6fb3e3', '#e3c39c']) {
110
+ assert.deepEqual(look.lookAdjustAccent(hex), { hex, adjusted: false }, hex);
111
+ assert.deepEqual(readsEverywhere(hex), [], hex);
112
+ }
113
+ });
114
+
115
+ await ta('every colour round the hue wheel comes back readable everywhere the hall paints it, its hue kept', async () => {
116
+ const fails = [];
117
+ let moved = 0;
118
+ for (let h = 0; h < 360; h += 10) {
119
+ for (const s of [0, 0.4, 0.8, 1]) {
120
+ for (const l of [0, 0.15, 0.35, 0.5, 0.65, 0.85, 1]) {
121
+ const hex = hslHex(h, s, l);
122
+ const out = look.lookAdjustAccent(hex);
123
+ if (!out) { fails.push(`${hex}: refused`); continue; }
124
+ const why = readsEverywhere(out.hex);
125
+ if (why.length) fails.push(`${hex} → ${out.hex}: ${why.join(', ')}`);
126
+ if (out.adjusted) moved++;
127
+ const before = hueOf(hex), after = hueOf(out.hex);
128
+ if (s >= 0.4 && l > 0 && l < 1 && before !== null && after !== null) {
129
+ const dh = Math.min(Math.abs(before - after), 360 - Math.abs(before - after));
130
+ if (dh > 8) fails.push(`${hex} → ${out.hex}: hue moved ${dh.toFixed(1)}°`);
131
+ }
132
+ }
133
+ }
134
+ }
135
+ assert.deepEqual(fails, []);
136
+ assert.ok(moved > 100, `sanity: the sweep must actually exercise the nudge (moved ${moved})`);
137
+ });
138
+
139
+ await ta('pure black and pure white are nudged to a readable grey, not refused', async () => {
140
+ for (const hex of ['#000000', '#ffffff']) {
141
+ const out = look.lookAdjustAccent(hex);
142
+ assert.ok(out && out.adjusted, hex);
143
+ assert.deepEqual(readsEverywhere(out.hex), [], `${hex} → ${out.hex}`);
144
+ }
145
+ });
146
+
147
+ await ta('only a #rrggbb is a colour', async () => {
148
+ for (const v of ['', 'red', '#fff', '#12345g', null, undefined, 'rgb(1,2,3)']) assert.equal(look.lookAdjustAccent(v), null, String(v));
149
+ assert.deepEqual(look.lookAdjustAccent('B48CF2'), { hex: '#b48cf2', adjusted: false }, 'a missing # is forgiven');
150
+ });
151
+
152
+ await ta("the wizard's copy of lookAdjustAccent IS the server's, character for character", async () => {
153
+ const fnText = (src) => {
154
+ const start = src.indexOf('function lookAdjustAccent(');
155
+ assert.ok(start >= 0, 'function lookAdjustAccent( not found');
156
+ let depth = 0;
157
+ for (let i = src.indexOf('{', start); i < src.length; i++) {
158
+ if (src[i] === '{') depth++;
159
+ else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1).replace(/\r\n/g, '\n').split('\n').map((l) => l.trim()).join('\n');
160
+ }
161
+ throw new Error('unbalanced');
162
+ };
163
+ const server = fnText(fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'look.js'), 'utf8'));
164
+ const client = fnText(fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8'));
165
+ assert.equal(client, server, 'edit both copies together (ADR 0358 D4)');
166
+ });
167
+
168
+ console.log('\nA logo is screened by its bytes:');
169
+
170
+ const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(40, 1)]);
171
+ const JPG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(40, 2)]);
172
+ const WEBP = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0, 0, 0, 0]), Buffer.from('WEBP'), Buffer.alloc(40, 3)]);
173
+ const SVG = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
174
+
175
+ await ta('PNG, JPEG and WebP pass, each named by its own magic number and hashed', async () => {
176
+ for (const [buf, type, ext] of [[PNG, 'image/png', 'png'], [JPG, 'image/jpeg', 'jpg'], [WEBP, 'image/webp', 'webp']]) {
177
+ const r = look.screenLogo(buf, type);
178
+ assert.equal(r.ok, true, type);
179
+ assert.equal(r.ext, ext);
180
+ assert.match(r.sha, /^[0-9a-f]{64}$/);
181
+ }
182
+ });
183
+
184
+ await ta('SVG is refused whatever it claims to be, and so is a renamed file, an empty one and an oversized one', async () => {
185
+ assert.equal(look.screenLogo(SVG, 'image/svg+xml').reason, 'bad_type');
186
+ assert.equal(look.screenLogo(SVG, 'image/png').reason, 'sniff_mismatch', 'an SVG labelled PNG');
187
+ assert.equal(look.screenLogo(JPG, 'image/png').reason, 'sniff_mismatch', 'a JPEG labelled PNG');
188
+ assert.equal(look.screenLogo(Buffer.alloc(0), 'image/png').reason, 'empty');
189
+ const big = Buffer.concat([PNG, Buffer.alloc(look.LOGO_MAX_BYTES)]);
190
+ assert.equal(look.screenLogo(big, 'image/png').reason, 'too_big');
191
+ assert.equal(look.screenLogo(Buffer.concat([PNG.subarray(0, 8), Buffer.alloc(look.LOGO_MAX_BYTES - 8)]), 'image/png').ok, true, 'exactly the cap passes');
192
+ });
193
+
194
+ // ── the routes, over a real mounted router ───────────────────────────────────
195
+ let instance = null;
196
+ let stored = [];
197
+ let events = [];
198
+ let enqueued = [];
199
+ let logos = new Map();
200
+ provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
201
+ provisioning.updateInstanceSettings = async (_db, id, patch) => {
202
+ stored.push(patch);
203
+ instance = { ...instance, settings: { ...(instance.settings || {}), ...patch } };
204
+ return { ...instance, id };
205
+ };
206
+ provisioning.recordEvent = async (_db, e) => { events.push(e); };
207
+ provisioning.enqueueSettingsApply = async (_db, id, by) => { enqueued.push({ id, by }); return { intent: { id: 3, action: 'settings-apply', state: 'pending' }, created: true }; };
208
+ provisioning.recordSettingsPushOwed = async () => {};
209
+ look.upsertLogo = async (_db, id, s, buf) => { logos.set(id, { sha: s.sha, ext: s.ext, mime: s.mime, bytes: buf }); };
210
+ look.deleteLogo = async (_db, id) => { logos.delete(id); };
211
+ look.readLogo = async (_db, sha, ext) => [...logos.values()].find((l) => l.sha === sha && l.ext === ext) || null;
212
+
213
+ const api = require('../src/module-api.js');
214
+ let builder = null;
215
+ api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
216
+ api.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
217
+ api.branding = () => ({ domains: { publicOrigin: 'https://platform.test' } });
218
+
219
+ const express = require('express');
220
+ const buildRouter = require('../modules/provisioning/routes/look.js');
221
+ const app = express();
222
+ app.use((req, res, next) => (/\/logo$/.test(req.path) ? next() : express.json()(req, res, next)));
223
+ app.use((_req, res, next) => {
224
+ res.fail = (code, opts) => {
225
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
226
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}) } });
227
+ };
228
+ next();
229
+ });
230
+ app.use('/api/bongos', buildRouter());
231
+ const server = app.listen(0);
232
+ await new Promise((r) => server.once('listening', r));
233
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
234
+
235
+ function call(method, p, body) {
236
+ return new Promise((resolve, reject) => {
237
+ const payload = body === undefined ? null : JSON.stringify(body);
238
+ const req = http.request(`${base}${p}`, { method, headers: payload ? { 'Content-Type': 'application/json' } : {} }, (res) => {
239
+ const chunks = [];
240
+ res.on('data', (c) => chunks.push(c));
241
+ res.on('end', () => {
242
+ const raw = Buffer.concat(chunks);
243
+ let j = null; try { j = JSON.parse(raw.toString('utf8')); } catch { /* binary */ }
244
+ resolve({ status: res.statusCode, body: j, raw, headers: res.headers });
245
+ });
246
+ });
247
+ req.on('error', reject);
248
+ if (payload) req.write(payload);
249
+ req.end();
250
+ });
251
+ }
252
+
253
+ const OWNER = { id: 7, rank: 'thetes' };
254
+ function reset(patch = {}) {
255
+ instance = { owner_builder_id: 7, slug: 'kestrel', status: 'active', hosting_shape: 'cloud-host', settings: {}, ...patch };
256
+ stored = []; events = []; enqueued = []; logos = new Map(); builder = OWNER;
257
+ }
258
+
259
+ console.log('\nThe routes:');
260
+
261
+ await ta("another builder's project is a 404 on every route, and nothing is written", async () => {
262
+ reset();
263
+ builder = { id: 99, rank: 'metic' };
264
+ for (const [m, p, b] of [['GET', '/provisioning/instances/5/look'], ['PATCH', '/provisioning/instances/5/look', { look: 'grove' }],
265
+ ['PUT', '/provisioning/instances/5/logo', { image_b64: PNG.toString('base64'), content_type: 'image/png' }], ['DELETE', '/provisioning/instances/5/logo']]) {
266
+ const r = await call(m, p, b);
267
+ assert.equal(r.status, 404, `${m} ${p}`);
268
+ }
269
+ assert.equal(stored.length + enqueued.length + logos.size, 0);
270
+ });
271
+
272
+ await ta('a library look on a live project is stored, audited and pushed with a restart', async () => {
273
+ reset();
274
+ const r = await call('PATCH', '/provisioning/instances/5/look', { look: 'grove', night_tint: 'off' });
275
+ assert.equal(r.status, 200, r.raw.toString());
276
+ assert.deepEqual(stored, [{ look: 'grove', night_tint: 'off' }]);
277
+ assert.equal(r.body.look.look, 'grove');
278
+ assert.equal(r.body.look.night_tint, 'off');
279
+ assert.equal(r.body.push.restart, true);
280
+ assert.equal(enqueued.length, 1, 'the settings-apply intent is queued');
281
+ assert.equal(events[0].actor, 'api:self');
282
+ assert.equal(r.body.adjusted, null);
283
+ });
284
+
285
+ await ta('a custom accent is stored ADJUSTED, and the reply names both colours', async () => {
286
+ reset();
287
+ const r = await call('PATCH', '/provisioning/instances/5/look', { look: 'custom', accent: '#1A2B3C' });
288
+ assert.equal(r.status, 200, r.raw.toString());
289
+ const kept = look.lookAdjustAccent('#1a2b3c').hex;
290
+ assert.deepEqual(stored[0], { look: 'custom', look_accent: kept });
291
+ assert.deepEqual(r.body.adjusted, { from: '#1a2b3c', to: kept });
292
+ assert.equal(r.body.look.look_accent, kept);
293
+ assert.deepEqual(readsEverywhere(kept), []);
294
+ });
295
+
296
+ await ta('a custom look with no accent anywhere is refused; one already stored is enough', async () => {
297
+ reset();
298
+ let r = await call('PATCH', '/provisioning/instances/5/look', { look: 'custom' });
299
+ assert.equal(r.status, 400);
300
+ assert.equal(r.body.error.code, 'accent_required');
301
+ assert.equal(stored.length, 0);
302
+ reset({ settings: { look_accent: '#b48cf2' } });
303
+ r = await call('PATCH', '/provisioning/instances/5/look', { look: 'custom' });
304
+ assert.equal(r.status, 200, r.raw.toString());
305
+ });
306
+
307
+ await ta('an accent that is not a colour, an unknown look and an empty body are 400s', async () => {
308
+ reset();
309
+ assert.equal((await call('PATCH', '/provisioning/instances/5/look', { look: 'custom', accent: 'tomato' })).body.error.code, 'bad_accent');
310
+ assert.equal((await call('PATCH', '/provisioning/instances/5/look', { look: 'neon' })).status, 400);
311
+ assert.equal((await call('PATCH', '/provisioning/instances/5/look', {})).body.error.code, 'bad_look');
312
+ assert.equal(stored.length, 0);
313
+ });
314
+
315
+ await ta('the same look re-sent writes nothing and restarts nothing', async () => {
316
+ reset({ settings: { look: 'grove' } });
317
+ const r = await call('PATCH', '/provisioning/instances/5/look', { look: 'grove' });
318
+ assert.equal(r.status, 200);
319
+ assert.equal(stored.length, 0);
320
+ assert.equal(enqueued.length, 0);
321
+ assert.match(r.body.push.message, /No change/);
322
+ });
323
+
324
+ await ta('a project still standing up stores the look and takes it at standup — no restart queued', async () => {
325
+ reset({ status: 'provisioning' });
326
+ const r = await call('PATCH', '/provisioning/instances/5/look', { look: 'blueprint' });
327
+ assert.equal(r.status, 200);
328
+ assert.equal(enqueued.length, 0);
329
+ assert.match(r.body.push.message, /finishes standing up/);
330
+ });
331
+
332
+ await ta('a logo is stored, the hall is told its https address, and the public read serves it as an image only', async () => {
333
+ reset();
334
+ const r = await call('PUT', '/provisioning/instances/5/logo', { image_b64: PNG.toString('base64'), content_type: 'image/png' });
335
+ assert.equal(r.status, 200, r.raw.toString());
336
+ const { sha } = look.screenLogo(PNG, 'image/png');
337
+ const url = `https://platform.test/api/bongos/provisioning/logos/${sha}.png`;
338
+ assert.equal(r.body.look.logo_url, url);
339
+ assert.equal(enqueued.length, 1);
340
+ const g = await call('GET', `/provisioning/logos/${sha}.png`);
341
+ assert.equal(g.status, 200);
342
+ assert.equal(g.headers['content-type'], 'image/png');
343
+ assert.equal(g.headers['x-content-type-options'], 'nosniff');
344
+ assert.match(g.headers['content-security-policy'], /sandbox/);
345
+ assert.match(g.headers['cache-control'], /immutable/);
346
+ assert.ok(g.raw.equals(PNG), 'the bytes come back as stored');
347
+ });
348
+
349
+ await ta('an SVG logo is refused and nothing is stored', async () => {
350
+ reset();
351
+ const r = await call('PUT', '/provisioning/instances/5/logo', { image_b64: SVG.toString('base64'), content_type: 'image/svg+xml' });
352
+ assert.equal(r.status, 400);
353
+ assert.equal(r.body.error.code, 'logo_bad_type');
354
+ assert.equal(logos.size + stored.length, 0);
355
+ });
356
+
357
+ await ta('removing the logo empties the address and pushes it', async () => {
358
+ reset({ settings: { logo_url: 'https://platform.test/api/bongos/provisioning/logos/x.png' } });
359
+ logos.set(5, { sha: 'x', ext: 'png', bytes: PNG });
360
+ const r = await call('DELETE', '/provisioning/instances/5/logo');
361
+ assert.equal(r.status, 200, r.raw.toString());
362
+ assert.deepEqual(stored, [{ logo_url: '' }]);
363
+ assert.equal(r.body.look.logo_url, null);
364
+ assert.equal(logos.size, 0);
365
+ });
366
+
367
+ await ta('the public read answers only a content-addressed name', async () => {
368
+ reset();
369
+ for (const name of ['5.png', '../etc/passwd', `${'a'.repeat(64)}.svg`, `${'a'.repeat(64)}.png`]) {
370
+ assert.equal((await call('GET', `/provisioning/logos/${encodeURIComponent(name)}`)).status, 404, name);
371
+ }
372
+ });
373
+
374
+ server.close();
375
+ console.log(`\nprovisioning_look: ${passed} passed, ${failed} failed`);
376
+ if (failed) process.exit(1);
@@ -164,7 +164,7 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
164
164
  reset();
165
165
  const r = await get('/provisioning/instances/18/settings');
166
166
  assert.equal(r.status, 200, r.raw);
167
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
167
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
168
168
  // the planet rides beside the settings (task 1003347): no default — null = the platform's own pick
169
169
  assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
170
170
  assert.deepEqual(r.body.options, {
@@ -183,6 +183,9 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
183
183
  // policy axis, offered to the form like the rest. What makes it different is
184
184
  // its CONSEQUENCE: it is the only setting that can refuse a module outright.
185
185
  spend_payer: ['none', 'project', 'builder'],
186
+ // the look a hall wears and its night tint (task 1004421, ADR 0358) — policy
187
+ // axes like the rest: they ride web.env and a restart applies them
188
+ look: ['own', 'chrome-world', 'expedition', 'grove', 'blueprint', 'custom'], night_tint: ['on', 'off'],
186
189
  planet_template: [...provisioning.PLANET_TEMPLATES], planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
187
190
  // the card's backdrop (task 1003352) is the same kind of key: platform-drawn,
188
191
  // closed list, no default, no push — so it rides the same vocab
@@ -195,14 +198,14 @@ await ta('a stored planet reads back; a name that left the vocabulary reads null
195
198
  const r = await get('/provisioning/instances/18/settings');
196
199
  assert.equal(r.status, 200, r.raw);
197
200
  assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null, card_backdrop: null });
198
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the planet is not a setting: the policy read is untouched');
201
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' }, 'the planet is not a setting: the policy read is untouched');
199
202
  });
200
203
 
201
204
  await ta('stored keys overlay the defaults', async () => {
202
205
  reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { joinability: 'invite_only' } } });
203
206
  const r = await get('/provisioning/instances/18/settings');
204
207
  assert.equal(r.status, 200, r.raw);
205
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
208
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
206
209
  });
207
210
 
208
211
  await ta('ANOTHER builder\'s GET → 404 (no existence leak)', async () => {
@@ -230,7 +233,7 @@ await ta('owner changes one key — merged, returned effective, audited api:self
230
233
  reset();
231
234
  const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
232
235
  assert.equal(r.status, 200, r.raw);
233
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
236
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
234
237
  assert.equal(r.body.push.queued, true, 'an active co-tenant gets the live push (task 1003140)');
235
238
  assert.deepEqual(updateCalls, [{ id: 18, patch: { joinability: 'open' } }], 'exactly the sent key, nothing else');
236
239
  assert.equal(events.length, 1);
@@ -243,7 +246,7 @@ await ta('owner changes both keys in one call', async () => {
243
246
  reset();
244
247
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'invite_only' });
245
248
  assert.equal(r.status, 200, r.raw);
246
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
249
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
247
250
  });
248
251
 
249
252
  await ta('the OWN-SCOPE WALL: another builder\'s PATCH → 404 and the write path is never reached', async () => {
@@ -282,7 +285,7 @@ await ta('the owner sets the door to stealth — stored, echoed effective, audit
282
285
  reset();
283
286
  const r = await patch('/provisioning/instances/18/settings', { visibility: 'stealth' });
284
287
  assert.equal(r.status, 200, r.raw);
285
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
288
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
286
289
  assert.deepEqual(updateCalls, [{ id: 18, patch: { visibility: 'stealth' } }], 'exactly the sent key, nothing else');
287
290
  assert.equal(events[0].actor, 'api:self');
288
291
  assert.match(events[0].detail, /visibility=stealth/);
@@ -342,7 +345,7 @@ await ta('the owner picks a planet: saved, echoed as `planet`, audited — and N
342
345
  const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
343
346
  assert.equal(r.status, 200, r.raw);
344
347
  assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea', card_backdrop: null });
345
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the policy half is untouched');
348
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' }, 'the policy half is untouched');
346
349
  assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }]);
347
350
  assert.equal(r.body.push.queued, false);
348
351
  assert.equal(r.body.push.restart, false, 'an active co-tenant would get a push for a POLICY change — never for the planet');
@@ -272,7 +272,7 @@ await ta('ACTIVE cloud-host: the row is written FIRST, then a settings-apply int
272
272
  reset();
273
273
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
274
274
  assert.equal(r.status, 200, r.raw);
275
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
275
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
276
276
  assert.equal(r.body.settings, undefined);
277
277
  assert.deepEqual(updateCalls.map((c) => c.patch), [{ platform_visibility: 'gated' }]);
278
278
  assert.equal(updateCalls[0].enqueuedSoFar, 0, 'the runner reads the row — the write must precede the intent');
@@ -335,7 +335,7 @@ await ta('an enqueue that THROWS still answers 200 "saved" — never a 500 that
335
335
  enqueueThrows = new Error('db down');
336
336
  const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
337
337
  assert.equal(r.status, 200, r.raw);
338
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
338
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
339
339
  assert.equal(updateCalls.length, 1, 'the write landed');
340
340
  receiptShape(r.body.push, { queued: false, restart: true, reachable: true });
341
341
  assert.match(r.body.push.message, /Saved, but the restart could not be queued/);
@@ -348,7 +348,7 @@ await ta('same values as already stored → no write, no event, no intent, "No c
348
348
  reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated', joinability: 'open' } } });
349
349
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'open' });
350
350
  assert.equal(r.status, 200, r.raw);
351
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
351
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' });
352
352
  assert.equal(updateCalls.length, 0, 'nothing written (no updated_at reshuffle)');
353
353
  assert.equal(events.length, 0, 'no "settings changed" event for a change that is not one');
354
354
  assert.equal(enqueueCalls.length, 0, 'a whole-form save must not bounce a live project');
@@ -479,7 +479,7 @@ await ta('save A queues → save B 409s while A RUNS → A settles → the UNCHA
479
479
  assert.equal(enqueueCalls.length, enqueuedBefore + 1, 'exactly one new intent');
480
480
  assert.equal(updateCalls.length, writesBefore, 'nothing re-written — the value was already stored in step 2');
481
481
  assert.deepEqual(owedCalls[owedCalls.length - 1], { id: 18, owed: false }, 'and the debt is settled');
482
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' },
482
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none', look: 'own', night_tint: 'on' },
483
483
  'the value that finally ships is the one the owner saved');
484
484
  });
485
485
 
@@ -121,6 +121,9 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
121
121
  // transport because src/modules.js reads it at CONFIG LOAD, which means the
122
122
  // instance must already have it in web.env before the first require.
123
123
  CLOUDBONGOS_SPEND_PAYER: 'none',
124
+ // The look and its night tint (ADR 0358, task 1004421), with their two companions:
125
+ // a custom look's accent and the logo address, empty when there is none.
126
+ CLOUDBONGOS_LOOK: 'own', CLOUDBONGOS_NIGHT_TINT: 'on', CLOUDBONGOS_LOOK_ACCENT: '', CLOUDBONGOS_LOGO_URL: '',
124
127
  });
125
128
  // varsOf splits on '=', so an empty value reads as '' on both sides — the point
126
129
  // of the comparison is that the standup lines carry exactly the same key set.
@@ -128,6 +131,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
128
131
  assert.deepEqual(provisioning.settingsEnvVars({}, 'X'), {
129
132
  X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
130
133
  X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
134
+ X_LOOK: 'own', X_NIGHT_TINT: 'on', X_LOOK_ACCENT: '', X_LOGO_URL: '',
131
135
  }, 'defaults, explicitly');
132
136
  });
133
137
 
@@ -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]]);