@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,255 @@
1
+ // tests/projects_hub_look.mjs — the Look step in the create wizard and "Its look" on the
2
+ // manage page (task 1004421, BV2.PS06; ADR 0358; spec D2 + D8).
3
+ //
4
+ // The load-bearing assertions:
5
+ // • the previews paint the REAL looks: every colour lookKit carries equals its
6
+ // pack.json, and the night shares equal src/night-tint.js — a preview that drifted
7
+ // from the hall would promise a look the hall does not wear;
8
+ // • the step sits between App and Review (look 10, review 11, done 12, draft v7), on
9
+ // the rail as a beat of its own, and every kit walk past App passes it;
10
+ // • EXECUTED by the vm-slice idiom: the picker offers the four looks and "make your
11
+ // own", shows the accent only for your own, shows the ADJUSTED colour in the
12
+ // preview and says so, paints a black night when the tint is off, refuses an SVG
13
+ // or oversized logo before reading it, and sends the look then the logo;
14
+ // • Create sends the look right after the project is filed, and the review names it;
15
+ // • the manage page reads the look once per project, never on the minute tick.
16
+ //
17
+ // Run: node --test tests/projects_hub_look.mjs
18
+
19
+ import { test } from 'node:test';
20
+ import assert from 'node:assert/strict';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import vm from 'node:vm';
24
+ import { createRequire } from 'node:module';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
30
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
31
+ const { NIGHT_TINT } = require('../src/night-tint.js');
32
+ const { LIBRARY_LOOKS, lookAdjustAccent } = require('../modules/provisioning/look.js');
33
+
34
+ // ── the lookKit slice, run in a vm under a small DOM stub ───────────────────────
35
+ const KIT_START = ' var lookKit = (function () {';
36
+ const KIT_END = '\n })();\n';
37
+ const kitStart = HUB.indexOf(KIT_START);
38
+ const kitEnd = HUB.indexOf(KIT_END, kitStart);
39
+ const KIT_SRC = HUB.slice(kitStart, kitEnd + KIT_END.length);
40
+
41
+ function makeDom() {
42
+ const all = [];
43
+ function node(tag) {
44
+ const n = {
45
+ tagName: String(tag).toUpperCase(), children: [], parentNode: null, style: { props: {}, setProperty(k, v) { this.props[k] = v; } },
46
+ attrs: {}, listeners: {}, hidden: false, value: '', checked: false, textContent: '', className: '', id: '', type: '', name: '', src: '', files: null,
47
+ get firstChild() { return this.children[0] || null; },
48
+ classList: null,
49
+ appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
50
+ removeChild(c) { this.children = this.children.filter((x) => x !== c); c.parentNode = null; return c; },
51
+ setAttribute(k, v) { this.attrs[k] = String(v); },
52
+ addEventListener(ev, fn) { (this.listeners[ev] ||= []).push(fn); },
53
+ fire(ev) { (this.listeners[ev] || []).forEach((fn) => fn.call(this, { target: this })); },
54
+ querySelector(sel) { return find(this, (x) => x.className.split(' ').includes(sel.replace(/^\./, ''))); },
55
+ };
56
+ n.classList = {
57
+ add(c) { const s = new Set(n.className.split(' ').filter(Boolean)); s.add(c); n.className = [...s].join(' '); },
58
+ remove(c) { n.className = n.className.split(' ').filter((x) => x && x !== c).join(' '); },
59
+ toggle(c, on) { if (on) this.add(c); else this.remove(c); },
60
+ contains(c) { return n.className.split(' ').includes(c); },
61
+ };
62
+ all.push(n);
63
+ return n;
64
+ }
65
+ function find(root, pred) {
66
+ for (const c of root.children) { if (pred(c)) return c; const d = find(c, pred); if (d) return d; }
67
+ return null;
68
+ }
69
+ const document = { createElement: node };
70
+ const byId = (root, id) => find(root, (x) => x.id === id);
71
+ return { document, node, byId, find };
72
+ }
73
+
74
+ function bootKit({ fetchImpl, reader } = {}) {
75
+ const dom = makeDom();
76
+ const sandbox = {
77
+ document: dom.document, API: '/api/bongos', JSON, Math, String, Number, Object, Array, Promise, parseInt,
78
+ fetch: fetchImpl || (() => Promise.reject(new Error('no fetch'))),
79
+ FileReader: reader || function FileReader() { this.readAsDataURL = (f) => { this.result = 'data:' + f.type + ';base64,QUJD'; this.onload(); }; },
80
+ };
81
+ vm.createContext(sandbox);
82
+ vm.runInContext(KIT_SRC.replace('var lookKit =', 'this.lookKit ='), sandbox);
83
+ return { kit: sandbox.lookKit, dom };
84
+ }
85
+
86
+ test('the slice is findable: a rename must fail here, not silently test nothing', () => {
87
+ assert.ok(kitStart > -1, 'var lookKit = (function () { is in the page');
88
+ assert.ok(kitEnd > kitStart, 'and it closes');
89
+ assert.match(KIT_SRC, /function buildLookPicker\(host, model, onChange, idp, projectName\)/);
90
+ });
91
+
92
+ test('every colour the previews paint is its pack\'s, and the night shares are the hall\'s', () => {
93
+ const { kit } = bootKit();
94
+ assert.deepEqual(Object.keys(kit.LOOKS).sort(), [...LIBRARY_LOOKS].sort(), 'one preview per library look');
95
+ for (const name of LIBRARY_LOOKS) {
96
+ const pack = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'styles', name, 'pack.json'), 'utf8')).theme.ui.colors;
97
+ assert.equal(JSON.stringify(kit.LOOKS[name].colors), JSON.stringify(pack), `${name}: the client copy equals pack.json, key for key`);
98
+ }
99
+ const share = (k) => Number(/var\(--accent\) (\d+)%/.exec(NIGHT_TINT[k])[1]);
100
+ assert.equal(kit.NIGHT.bgShare, share('--bg'));
101
+ assert.equal(kit.NIGHT.cardShare, share('--bg-card'));
102
+ assert.match(NIGHT_TINT['--bg'], /#000000\)$/);
103
+ assert.match(NIGHT_TINT['--bg-card'], /#101012\)$/);
104
+ assert.equal(kit.NIGHT.bg, '#000000');
105
+ assert.equal(kit.NIGHT.card, '#101012');
106
+ });
107
+
108
+ test('the step sits between App and Review: look 10, review 11, done 12, draft v7, a beat on the rail', () => {
109
+ assert.match(HUB, /9: 'panel-app', 10: 'panel-look', 11: 'panel-review', 12: 'panel-done' \};/);
110
+ assert.match(HUB, /var DRAFT_KEY = 'cb-create-draft-v7';/);
111
+ assert.match(HUB, /<li data-step="10"><span class="snum">8<\/span><span class="slbl">Look<\/span><\/li>\n\s*<li data-step="11"><span class="snum">9<\/span><span class="slbl">Create<\/span><\/li>/);
112
+ const panel = (HUB.match(/<section class="panel" id="panel-look"[\s\S]*?<\/section>/) || [''])[0];
113
+ assert.match(panel, /<h1 id="look-h">Give it a look<\/h1>/);
114
+ assert.match(panel, /<form data-next="11">/);
115
+ assert.match(panel, /data-back="9"/);
116
+ assert.match(panel, /<div class="lookPick" id="wizLook"><\/div>/);
117
+ assert.ok(HUB.indexOf('id="panel-app"') < HUB.indexOf('id="panel-look"') && HUB.indexOf('id="panel-look"') < HUB.indexOf('id="panel-review"'), 'in document order too');
118
+ assert.match(HUB, /if \(step === 10\) renderLook\(\);\n\s*if \(step === 11\) renderReview\(\);/);
119
+ assert.match(fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8'), /#view-new \.steps\{display:grid;grid-template-columns:repeat\(9,1fr\);/, 'a track per numbered step');
120
+ });
121
+
122
+ test('every kit walk past the App step passes the Look step, and the kit has a state on it', () => {
123
+ const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
124
+ const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes;
125
+ const walks = [...Object.entries(states).map(([k, v]) => [k, v.actions]), ...probes.map((p) => [p.name, p.steps])];
126
+ let past = 0;
127
+ for (const [name, steps] of walks) {
128
+ const at = (steps || []).findIndex((a) => a[1] === '#panel-app button[type=submit]');
129
+ if (at < 0 || /^in-new-look/.test(name)) continue; // the Look states stop ON the step
130
+ past++;
131
+ const look = steps.findIndex((a) => a[1] === '#panel-look button[type=submit]');
132
+ assert.ok(look > at, `${name}: submits the Look step after the App step`);
133
+ }
134
+ assert.ok(past >= 5, `sanity: the walks that reach the review were found (${past})`);
135
+ assert.deepEqual(states['in-new-look'].expect.visible.slice(0, 2), ['#panel-look', '#wizLook']);
136
+ assert.ok(states['in-new-look-custom'].actions.some((a) => a[1] === '#wizLook-custom'), 'a state pictures "make your own"');
137
+ });
138
+
139
+ test('executed: the picker offers the four looks and your own, and the accent shows only for your own', () => {
140
+ const { kit, dom } = bootKit();
141
+ const host = dom.node('div');
142
+ const model = { look: 'chrome-world', nightTint: true, accent: '#b48cf2', logo: null };
143
+ let changes = 0;
144
+ kit.buildLookPicker(host, model, () => { changes++; }, 'wiz', () => 'Kestrel');
145
+ const radios = ['chrome-world', 'expedition', 'grove', 'blueprint', 'custom'].map((k) => dom.byId(host, 'wizLook-' + k));
146
+ assert.ok(radios.every(Boolean), 'five radios, one per choice');
147
+ assert.equal(radios[0].checked, true, 'the current look is ticked');
148
+ const accentWrap = dom.byId(host, 'wizAccent').parentNode.parentNode;
149
+ assert.equal(accentWrap.hidden, true, 'no accent for a library look');
150
+ radios[4].checked = true; radios[4].fire('change');
151
+ assert.equal(model.look, 'custom');
152
+ assert.equal(accentWrap.hidden, false, 'the accent appears for your own');
153
+ assert.equal(changes, 1, 'onChange ran');
154
+ const name = dom.find(host, (x) => x.className === 'mhName');
155
+ assert.equal(name.textContent, 'Kestrel', 'the preview names the project');
156
+ });
157
+
158
+ test('executed: an unreadable accent is shown ADJUSTED in the preview, with a sentence saying so', () => {
159
+ const { kit, dom } = bootKit();
160
+ const host = dom.node('div');
161
+ const model = { look: 'custom', nightTint: true, accent: '#112233', logo: null };
162
+ kit.buildLookPicker(host, model, null, 'wiz');
163
+ const kept = lookAdjustAccent('#112233').hex;
164
+ const note = dom.byId(host, 'wizAccentNote');
165
+ assert.match(note.textContent, new RegExp(`Adjusted to ${kept} so text stays readable in both modes`));
166
+ const light = dom.find(host, (x) => x.className === 'mh light');
167
+ assert.equal(light.style.props['--mh-accent'], kept, 'the preview paints the colour the server will keep');
168
+ model.accent = '#b48cf2';
169
+ const picker = kit.buildLookPicker(host, model, null, 'wiz');
170
+ picker.repaint();
171
+ assert.equal(dom.byId(host, 'wizAccentNote').textContent, ' ', 'a readable colour needs no sentence');
172
+ });
173
+
174
+ test('executed: the dark preview is the look\'s tinted night, or pure black with the tint off', () => {
175
+ const { kit, dom } = bootKit();
176
+ const host = dom.node('div');
177
+ const model = { look: 'grove', nightTint: true, accent: '#b48cf2', logo: null };
178
+ kit.buildLookPicker(host, model, null, 'wiz');
179
+ const dark = () => dom.find(host, (x) => x.className === 'mh dark');
180
+ const accent = kit.LOOKS.grove.colors.accent;
181
+ const mix = (hex, p, base) => '#' + [16, 8, 0].map((sh) => Math.round(((parseInt(hex.slice(1), 16) >> sh) & 255) * p / 100 + ((parseInt(base.slice(1), 16) >> sh) & 255) * (100 - p) / 100).toString(16).padStart(2, '0')).join('');
182
+ assert.equal(dark().style.props['--mh-bg'], mix(accent, 6, '#000000'));
183
+ assert.equal(dark().style.props['--mh-card'], mix(accent, 5, '#101012'));
184
+ const tint = dom.byId(host, 'wizTint');
185
+ tint.checked = false; tint.fire('change');
186
+ assert.equal(model.nightTint, false);
187
+ assert.equal(dark().style.props['--mh-bg'], '#000000');
188
+ assert.equal(dark().style.props['--mh-card'], '#101012');
189
+ // a project on its own colours (manage page) never shows a tinted night
190
+ const own = { look: 'own', nightTint: true, accent: '#b48cf2', logo: null };
191
+ const h2 = dom.node('div');
192
+ kit.buildLookPicker(h2, own, null, 'mLk');
193
+ assert.equal(dom.find(h2, (x) => x.className === 'mh dark').style.props['--mh-bg'], '#000000');
194
+ assert.ok(['chrome-world', 'expedition', 'grove', 'blueprint', 'custom'].every((k) => !dom.byId(h2, 'mLkLook-' + k).checked), 'no look is ticked for a project that never chose');
195
+ });
196
+
197
+ test('executed: a logo is refused before it is read unless it is a small PNG, JPEG or WebP', () => {
198
+ let reads = 0;
199
+ const reader = function FileReader() { this.readAsDataURL = (f) => { reads++; this.result = 'data:' + f.type + ';base64,QUJD'; this.onload(); }; };
200
+ const { kit, dom } = bootKit({ reader });
201
+ const host = dom.node('div');
202
+ const model = { look: 'grove', nightTint: true, accent: '#b48cf2', logo: null };
203
+ kit.buildLookPicker(host, model, null, 'wiz');
204
+ const file = dom.byId(host, 'wizLogo');
205
+ const note = file.parentNode.parentNode.children.find((c) => c.attrs.role === 'status');
206
+ file.files = [{ type: 'image/svg+xml', size: 100, name: 'k.svg' }]; file.fire('change');
207
+ assert.match(note.textContent, /PNG, JPEG or WebP/);
208
+ file.files = [{ type: 'image/png', size: 300 * 1024, name: 'k.png' }]; file.fire('change');
209
+ assert.match(note.textContent, /at most 256 KB/);
210
+ assert.equal(reads, 0, 'neither was read');
211
+ assert.equal(model.logo, null);
212
+ file.files = [{ type: 'image/png', size: 2000, name: 'k.png' }]; file.fire('change');
213
+ assert.equal(reads, 1);
214
+ assert.deepEqual(JSON.parse(JSON.stringify(model.logo)), { type: 'image/png', b64: 'QUJD', name: 'k.png' });
215
+ const mark = dom.find(host, (x) => x.className === 'mhMark logo');
216
+ assert.ok(mark && mark.children[0].src.startsWith('data:image/png;base64,'), 'the preview\'s hall mark shows the logo');
217
+ });
218
+
219
+ test('executed: sendLook sends the look, then the logo — and a removed logo is deleted', async () => {
220
+ const calls = [];
221
+ const fetchImpl = (url, opts) => {
222
+ calls.push({ url, method: opts.method, body: opts.body ? JSON.parse(opts.body) : null });
223
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ ok: true, look: { look: 'custom' }, push: { message: 'Saved.' }, adjusted: null }) });
224
+ };
225
+ const { kit } = bootKit({ fetchImpl });
226
+ await kit.sendLook(42, { look: 'custom', nightTint: false, accent: '#b48cf2', logo: { type: 'image/webp', b64: 'QUJD', name: 'k.webp' } });
227
+ assert.deepEqual(JSON.parse(JSON.stringify(calls)), [
228
+ { url: '/api/bongos/provisioning/instances/42/look', method: 'PATCH', body: { look: 'custom', night_tint: 'off', accent: '#b48cf2' } },
229
+ { url: '/api/bongos/provisioning/instances/42/logo', method: 'PUT', body: { image_b64: 'QUJD', content_type: 'image/webp' } },
230
+ ]);
231
+ calls.length = 0;
232
+ await kit.sendLook(42, { look: 'grove', nightTint: true, accent: '#b48cf2', logo: null, logoDropped: true });
233
+ assert.deepEqual(calls.map((c) => [c.method, c.url.split('/').pop(), c.body]), [['PATCH', 'look', { look: 'grove', night_tint: 'on' }], ['DELETE', 'logo', null]]);
234
+ });
235
+
236
+ test('Create sends the look right after the project is filed, and the review names it', () => {
237
+ assert.match(HUB, /sendRenderSetup\(data\.instance && data\.instance\.id\);[^\n]*\n\s*sendLookSetup\(data\.instance && data\.instance\.id\);/);
238
+ assert.match(HUB, /lookKit\.sendLook\(instanceId, state\.look \|\| defaultLook\(\)\)/);
239
+ assert.match(HUB, /function defaultLook\(\) \{ return \{ look: 'chrome-world', nightTint: true, accent: '#b48cf2', logo: null \}; \}/, 'the default is the platform\'s look with the tint ON (owner D2)');
240
+ assert.match(HUB, /\['Look', lookRow\(\)\]/);
241
+ assert.match(HUB, /<p class="hint" id="doneLook" role="status" hidden><\/p>/, 'a refused look is said on the done panel');
242
+ assert.match(HUB, /look: null, termsAck: false \};/, 'the draft carries the look (and its reset clears it)');
243
+ });
244
+
245
+ test('the manage page has "Its look", read once per project — never on the minute tick', () => {
246
+ const sec = (HUB.match(/<div class="card mSec" id="mLook">[\s\S]*?\n {6}<\/div>/) || [''])[0];
247
+ assert.match(sec, /<h2>Its look<\/h2>/);
248
+ assert.match(sec, /<div id="mLookPick">/);
249
+ assert.match(sec, /id="mLookSave">Save the look<\/button>/);
250
+ assert.match(sec, /Saving restarts your project once/);
251
+ assert.match(HUB, /renderCardPick\(inst\);\n\s*renderLookCard\(inst\);/);
252
+ assert.match(HUB, /function renderLookCard\(inst\) \{\n\s*if \(mLookFor === inst\.id\) return;/, 'the tick does not re-read or rebuild it');
253
+ assert.match(HUB, /lookKit\.buildLookPicker\(host, mLookModel,/, 'the wizard\'s own picker');
254
+ assert.match(HUB, /lookKit\.sendLook\(manageId, mLookModel\)/, 'and its own send');
255
+ });
@@ -415,7 +415,7 @@ test('the review names the set even when the picker was never opened (task 10023
415
415
  // and the module NAMES are not known. "None — just the core" would be a false
416
416
  // promise on the last screen before Create: the server is about to apply the
417
417
  // type's bundle. The row says that instead, then repaints when the read lands.
418
- const h = boot({ state: { projType: 'game', step: 10 } }); // 10 = the review since task 1004417 (8 since 1004351)
418
+ const h = boot({ state: { projType: 'game', step: 11 } }); // 11 = the review since task 1004421 (10 since 1004417, 8 since 1004351)
419
419
 
420
420
  // Arrange + Act — the review asks for the value before the read can answer
421
421
  const first = h.extrasRow();
@@ -821,7 +821,7 @@ test('with nothing switched on the count says only what is shown', async () => {
821
821
  });
822
822
 
823
823
  test('the review names the hidden picks too — a filter never reaches the create request', async () => {
824
- const h = boot({ state: { projType: 'game', step: 10 } }); // 10 = the review since task 1004417 (8 since 1004351)
824
+ const h = boot({ state: { projType: 'game', step: 11 } }); // 11 = the review since task 1004421 (10 since 1004417, 8 since 1004351)
825
825
  await h.open();
826
826
  h.click('agents');
827
827
  assert.deepEqual(h.forCreate(), ['agents', 'npm-release', 'discord']);
@@ -70,7 +70,11 @@ ${NEUTRAL_PREFIX}_VISIBILITY=public
70
70
  ${NEUTRAL_PREFIX}_JOIN_GRANT=full
71
71
  ${NEUTRAL_PREFIX}_ARTIST_GATE=strict
72
72
  ${NEUTRAL_PREFIX}_SPEND_PAYER=none
73
+ ${NEUTRAL_PREFIX}_LOOK=own
74
+ ${NEUTRAL_PREFIX}_NIGHT_TINT=on
73
75
  ${NEUTRAL_PREFIX}_ARTIST_GATE_SINCE=
76
+ ${NEUTRAL_PREFIX}_LOOK_ACCENT=
77
+ ${NEUTRAL_PREFIX}_LOGO_URL=
74
78
  `;
75
79
 
76
80
  t('replaces an existing KEY= line where it stands; every other line survives byte-for-byte', () => {
@@ -134,6 +138,10 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
134
138
  // must already hold it in web.env before its first require
135
139
  [`${NEUTRAL_PREFIX}_SPEND_PAYER`]: 'none',
136
140
  [`${NEUTRAL_PREFIX}_ARTIST_GATE_SINCE`]: '',
141
+ // the look a hall wears and its night tint (task 1004421, ADR 0358), with the
142
+ // custom accent and the logo address as companions, empty when there is none
143
+ [`${NEUTRAL_PREFIX}_LOOK`]: 'own', [`${NEUTRAL_PREFIX}_NIGHT_TINT`]: 'on',
144
+ [`${NEUTRAL_PREFIX}_LOOK_ACCENT`]: '', [`${NEUTRAL_PREFIX}_LOGO_URL`]: '',
137
145
  });
138
146
  const body = P.webEnvBody(inst, {});
139
147
  for (const [k, v] of Object.entries(vars)) assert.match(body, new RegExp(`^${k}=${v}$`, 'm'), `${k} at standup`);
@@ -145,7 +153,7 @@ const GATED = { settings: { platform_visibility: 'gated', joinability: 'invite_o
145
153
  const manifestWith = (project) => JSON.stringify({ schema: 'instance-manifest/v1', brand: project === undefined ? {} : { project } });
146
154
 
147
155
  t('true when every pushed key matches brand.project (camelCase, the ENV_OVERRIDES targets)', () => {
148
- assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict', spendPayer: 'none' }), GATED), true);
156
+ assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict', spendPayer: 'none', look: 'own', nightTint: 'on' }), GATED), true);
149
157
  });
150
158
  t('false when the instance still reports a different value', () => {
151
159
  assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'public', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict', spendPayer: 'none' }), GATED), false);
@@ -166,7 +174,7 @@ t('instanceManifestCmd asks the instance on loopback, like healthzCmd', () => {
166
174
  function recorder({ apply = false, envBody = LIVE_ENV, readFails = false, healthzFails = false, privileged = false,
167
175
  manifest = 'match', restartThrows = false, writeThrows = false } = {}) {
168
176
  const cmds = [], writes = [], events = [], probes = [], cleared = [], logs = [];
169
- const manifestOut = manifest === 'match' ? manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict', spendPayer: 'none' })
177
+ const manifestOut = manifest === 'match' ? manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict', spendPayer: 'none', look: 'own', nightTint: 'on' })
170
178
  : manifest === 'mismatch' ? manifestWith({ platformVisibility: 'public', joinability: 'apply', visibility: 'public' })
171
179
  : manifest === 'none' ? manifestWith(undefined) : 'nope';
172
180
  const exec = (cmd, opts = {}) => {
@@ -234,7 +242,7 @@ await ta('dry-run PLANS the patch: reads nothing real, writes NOTHING (no synthe
234
242
  assert.equal(r.ok, true);
235
243
  assert.equal(r.healthy, null, 'nothing ran, nothing observed');
236
244
  assert.equal(writes.length, 0, 'a dry-run must not plan a two-line overwrite of web.env');
237
- assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_ARTIST_GATE_SINCE in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
245
+ assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_LOOK, .*_NIGHT_TINT, .*_ARTIST_GATE_SINCE, .*_LOOK_ACCENT, .*_LOGO_URL in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
238
246
  assert.ok(cmds.some((c) => /restart spike/.test(c)), 'the restart is still in the plan');
239
247
  assert.ok(logs.some((l) => /dry-run — nothing ran/.test(l)), 'the closing line does not claim a restart happened');
240
248
  });
@@ -354,7 +362,7 @@ await ta('--apply, the control-plane row: its OWN /etc/<slug>/web.env on the con
354
362
  const mk = (bucket) => (cmd) => {
355
363
  seen[bucket].push(cmd);
356
364
  if (/\bcat /.test(cmd)) return { ok: true, stdout: selfEnv };
357
- if (/api\/bongos\/instance/.test(cmd)) return { ok: true, stdout: manifestWith({ platformVisibility: 'public', joinability: 'apply', visibility: 'public', joinGrant: 'full', artistGate: 'off', spendPayer: 'none' }) };
365
+ if (/api\/bongos\/instance/.test(cmd)) return { ok: true, stdout: manifestWith({ platformVisibility: 'public', joinability: 'apply', visibility: 'public', joinGrant: 'full', artistGate: 'off', spendPayer: 'none', look: 'own', nightTint: 'on' }) };
358
366
  return { ok: true, stdout: '{"ok":true}' };
359
367
  };
360
368
  try {