@bongos/core 1.20.59 → 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 (89) hide show
  1. package/.bongos-core.json +136 -86
  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 +13 -0
  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 +308 -3
  10. package/docs/api-reference.md +7 -2
  11. package/docs/copy-inventory.md +338 -329
  12. package/docs/copy-registry.json +515 -430
  13. package/docs/module-api-changelog.md +2 -0
  14. package/docs/page-inventory.json +4 -1
  15. package/docs/page-readings.json +757 -706
  16. package/modules/hall-ui/public/atlas.html +1 -1
  17. package/modules/hall-ui/public/blockers.html +1 -1
  18. package/modules/hall-ui/public/board-room.html +1 -1
  19. package/modules/hall-ui/public/collab.html +1 -1
  20. package/modules/hall-ui/public/commands.html +1 -1
  21. package/modules/hall-ui/public/copy-desk.html +1 -1
  22. package/modules/hall-ui/public/deploy.html +1 -1
  23. package/modules/hall-ui/public/diagrams.html +1 -1
  24. package/modules/hall-ui/public/drachmae.html +1 -1
  25. package/modules/hall-ui/public/fleet.html +1 -1
  26. package/modules/hall-ui/public/gate.html +1 -1
  27. package/modules/hall-ui/public/goal-map.html +1 -1
  28. package/modules/hall-ui/public/goals.html +1 -1
  29. package/modules/hall-ui/public/government.html +1 -1
  30. package/modules/hall-ui/public/idea.html +1 -1
  31. package/modules/hall-ui/public/ideas.html +1 -1
  32. package/modules/hall-ui/public/index.html +1 -1
  33. package/modules/hall-ui/public/modules.html +1 -1
  34. package/modules/hall-ui/public/primer.html +1 -1
  35. package/modules/hall-ui/public/profile.html +1 -1
  36. package/modules/hall-ui/public/project-settings.html +1 -1
  37. package/modules/hall-ui/public/ranks.html +1 -1
  38. package/modules/hall-ui/public/roadmap.html +1 -1
  39. package/modules/hall-ui/public/roster.html +1 -1
  40. package/modules/hall-ui/public/sessions.html +1 -1
  41. package/modules/hall-ui/public/settings.html +1 -1
  42. package/modules/hall-ui/public/shell.js +5 -1
  43. package/modules/hall-ui/public/studio.html +1 -1
  44. package/modules/hall-ui/public/style.css +8 -0
  45. package/modules/hall-ui/public/task.html +1 -1
  46. package/modules/hall-ui/public/thinking.html +1 -1
  47. package/modules/hall-ui/public/tweak-editor.html +1 -1
  48. package/modules/hall-ui/public/watch.html +1 -1
  49. package/modules/hall-ui/public/work.html +1 -1
  50. package/modules/provisioning/look.js +169 -0
  51. package/modules/provisioning/migrations/provisioning_034_project_logo.sql +35 -0
  52. package/modules/provisioning/module.json +2 -1
  53. package/modules/provisioning/provisioning.js +4 -3
  54. package/modules/provisioning/routes/look.js +204 -0
  55. package/modules/provisioning/routes/provisioning.js +5 -59
  56. package/modules/provisioning/settings-push.js +76 -0
  57. package/modules/public-landing/public/assets/cosmos.css +1 -1
  58. package/modules/public-landing/public/projects.html +499 -28
  59. package/modules/public-landing/public/projects.probes.json +2 -2
  60. package/modules/public-landing/public/projects.states.json +6 -3
  61. package/modules/ui-design/kit/serve.js +50 -0
  62. package/package-lock.json +2 -2
  63. package/package.json +1 -1
  64. package/release-notes.json +6 -0
  65. package/scripts/gds/fitness.js +4 -0
  66. package/src/bongos/routes.js +4 -1
  67. package/src/bongos/serve-internal.js +6 -0
  68. package/src/branding.js +26 -1
  69. package/src/hall-look.js +99 -0
  70. package/src/module-api.js +1 -1
  71. package/src/night-tint.js +26 -0
  72. package/tests/currency_label.mjs +7 -4
  73. package/tests/hall_tokens.mjs +10 -1
  74. package/tests/hall_tweak_editor.mjs +1 -1
  75. package/tests/look_night_tint.mjs +169 -0
  76. package/tests/nav_permission_atoms.mjs +1 -1
  77. package/tests/projects_hub.mjs +5 -4
  78. package/tests/projects_hub_app_status.mjs +1 -1
  79. package/tests/projects_hub_app_step.mjs +8 -7
  80. package/tests/projects_hub_look.mjs +255 -0
  81. package/tests/projects_hub_module_picker.mjs +2 -2
  82. package/tests/provision_settings_apply.mjs +12 -4
  83. package/tests/provisioning_look.mjs +376 -0
  84. package/tests/provisioning_settings.mjs +10 -7
  85. package/tests/provisioning_settings_apply.mjs +4 -4
  86. package/tests/provisioning_settings_env.mjs +4 -0
  87. package/tests/wizard_draft_resume.mjs +2 -2
  88. package/tests/wizard_front_door.mjs +2 -2
  89. package/tests/wizard_intent_resume.mjs +16 -15
@@ -0,0 +1,169 @@
1
+ // tests/look_night_tint.mjs — the look a hall wears, its night tint and its logo, hall
2
+ // side (task 1004421, BV2.PS06; ADR 0358).
3
+ //
4
+ // The load-bearing assertions:
5
+ // • THE BLEND RULE: a hall that never chose a look is exactly today's hall — the
6
+ // same theme, the same markup, pure black at night — whatever its tint setting;
7
+ // • a pushed look name becomes that look's fifteen on the instance, read from the
8
+ // style library the core ships; an unknown name or a malformed accent leaves the
9
+ // hall on its own pack rather than half-applying;
10
+ // • the night tint is emitted only with a look, as both dark twins, after the sheet;
11
+ // • CONTRAST: the hall's own token suite (tests/hall_tokens.mjs) passes under every
12
+ // library look with the tint on, and under custom accents round the hue wheel
13
+ // after the platform's nudge — light, dark and tinted dark;
14
+ // • a logo address reaches the hall mark and the favicon only when it is a plain
15
+ // https URL.
16
+ //
17
+ // Run: node --test tests/look_night_tint.mjs
18
+
19
+ import { test } from 'node:test';
20
+ import assert from 'node:assert/strict';
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { spawnSync } from 'node:child_process';
25
+ import { createRequire } from 'node:module';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
+ const { resolveBranding, clientBranding } = require('../src/branding.js');
31
+ const { themeMarkup } = require('../src/bongos/serve-internal.js');
32
+ const { NIGHT_TINT, nightTintCss } = require('../src/night-tint.js');
33
+ const { lookAdjustAccent, LIBRARY_LOOKS } = require('../modules/provisioning/look.js');
34
+
35
+ const neutral = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8'));
36
+ const brand = (env = {}) => clientBranding(resolveBranding({ neutral, env }));
37
+ const packOf = (name) => JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'styles', name, 'pack.json'), 'utf8')).theme.ui;
38
+
39
+ test('the blend rule: no look means today\'s hall, whatever the tint setting says', () => {
40
+ const today = brand();
41
+ for (const env of [{ BONGOS_LOOK: 'own' }, { BONGOS_NIGHT_TINT: 'on' }, { BONGOS_LOOK: 'own', BONGOS_NIGHT_TINT: 'on' }]) {
42
+ const b = brand(env);
43
+ assert.deepEqual(b.theme, today.theme, JSON.stringify(env));
44
+ assert.equal(themeMarkup(b.theme), themeMarkup(today.theme));
45
+ }
46
+ assert.equal(today.theme.nightTint, false);
47
+ assert.equal(today.theme.look, '');
48
+ assert.ok(!themeMarkup(today.theme).includes('data-theme'), 'no dark block is injected for a hall without a look');
49
+ assert.deepEqual(today.theme.colors, neutral.theme.ui.colors);
50
+ });
51
+
52
+ test('a library look becomes its fifteen, and the manifest reads back what was pushed', () => {
53
+ for (const name of LIBRARY_LOOKS) {
54
+ const b = brand({ BONGOS_LOOK: name });
55
+ const ui = packOf(name);
56
+ assert.deepEqual(b.theme.colors, ui.colors, name);
57
+ assert.deepEqual(b.theme.fonts, { display: ui.fonts.display, body: ui.fonts.body, links: ui.fonts.links }, name);
58
+ assert.equal(b.theme.look, name);
59
+ assert.equal(b.project.look, name, 'the settings read-back (settingsConsumed) sees the pushed name');
60
+ assert.equal(b.theme.nightTint, true, 'the tint is on by default with a look');
61
+ assert.match(b.theme.favicon, new RegExp(encodeURIComponent(ui.colors.accent)), 'the tab icon is drawn in the look\'s accent');
62
+ }
63
+ assert.equal(brand({ BONGOS_LOOK: 'grove', BONGOS_NIGHT_TINT: 'off' }).theme.nightTint, false, 'the founder can switch back to pure black');
64
+ assert.equal(brand({ BONGOS_LOOK: 'grove', BONGOS_NIGHT_TINT: 'off' }).project.nightTint, 'off');
65
+ });
66
+
67
+ test('a custom look is the chrome world with one accent; an unusable accent or an unknown look leaves the hall alone', () => {
68
+ const b = brand({ BONGOS_LOOK: 'custom', BONGOS_LOOK_ACCENT: '#B48CF2' });
69
+ assert.deepEqual(b.theme.colors, { ...packOf('chrome-world').colors, accent: '#b48cf2' });
70
+ assert.equal(b.theme.look, 'custom');
71
+ const today = brand();
72
+ for (const env of [{ BONGOS_LOOK: 'custom' }, { BONGOS_LOOK: 'custom', BONGOS_LOOK_ACCENT: 'red;}body{x' }, { BONGOS_LOOK: 'neon' }, { BONGOS_LOOK: '../../etc' }]) {
73
+ assert.deepEqual(brand(env).theme, today.theme, JSON.stringify(env));
74
+ }
75
+ });
76
+
77
+ test('the night tint is emitted with a look, as both dark twins, and nowhere else', () => {
78
+ const on = themeMarkup(brand({ BONGOS_LOOK: 'expedition' }).theme);
79
+ assert.ok(on.includes(nightTintCss()), 'the tint block is in the markup');
80
+ assert.ok(on.indexOf(':root{') < on.indexOf(nightTintCss()), 'after the palette, so the dark grounds win on order');
81
+ assert.equal(nightTintCss().split('--bg:').length - 1, 2, 'written twice: the [data-theme] switch and the media twin');
82
+ assert.match(nightTintCss(), /html\[data-theme="dark"\]\{/);
83
+ assert.match(nightTintCss(), /@media \(prefers-color-scheme: dark\)\{html:not\(\[data-theme\]\)\{/);
84
+ for (const v of Object.values(NIGHT_TINT)) assert.match(v, /^color-mix\(in srgb, var\(--accent\) \d+%, #[0-9a-f]{6}\)$/, 'derived from the look\'s own accent, never a literal colour');
85
+ assert.ok(!themeMarkup(brand({ BONGOS_LOOK: 'expedition', BONGOS_NIGHT_TINT: 'off' }).theme).includes('data-theme'));
86
+ });
87
+
88
+ test('a logo reaches the hall only as a plain https address, and becomes its favicon', () => {
89
+ const url = 'https://platform.test/api/bongos/provisioning/logos/' + 'a'.repeat(64) + '.png';
90
+ const b = brand({ BONGOS_LOGO_URL: url });
91
+ assert.equal(b.theme.logo, url);
92
+ assert.equal(b.theme.favicon, url);
93
+ assert.equal(b.theme.nightTint, false, 'a logo alone tints nothing');
94
+ assert.deepEqual(b.theme.colors, neutral.theme.ui.colors, 'a logo alone changes no colour');
95
+ for (const bad of ['http://x.test/a.png', 'javascript:alert(1)', 'https://x.test/a.png"><script>', 'https://x.test/a b.png', 'https://x.test/a.png)']) {
96
+ assert.equal(brand({ BONGOS_LOGO_URL: bad }).theme.logo, '', bad);
97
+ }
98
+ const shell = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'shell.js'), 'utf8');
99
+ assert.match(shell, /brand\.theme && brand\.theme\.logo\s*\?\s*'<img class="brand-mark__logo" src="' \+ esc\(brand\.theme\.logo\)/, 'the hall mark shows the logo, escaped, in the dot\'s place');
100
+ assert.match(fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'style.css'), 'utf8'), /\.brand-mark__logo \{/);
101
+ });
102
+
103
+ // ── contrast: the hall's own suite, under each look, with the tint on ──────────────
104
+ const childEnv = (extra) => {
105
+ const env = { ...process.env, ...extra };
106
+ delete env.NODE_TEST_CONTEXT; // a nested node --test must run its own tests, not report to ours
107
+ return env;
108
+ };
109
+ function hallSuite(extra) {
110
+ const r = spawnSync(process.execPath, ['--test', '--test-reporter=tap', 'tests/hall_tokens.mjs'], {
111
+ cwd: ROOT, encoding: 'utf8', env: childEnv({ UI_DESIGN_TINT: '1', ...extra }), timeout: 120000,
112
+ });
113
+ const fails = (r.stdout || '').split('\n').filter((l) => /= \d+\.\d+:1/.test(l)).map((l) => l.trim());
114
+ return { status: r.status, fails };
115
+ }
116
+
117
+ test('the hall clears AA under every library look with the night tint on', () => {
118
+ for (const name of LIBRARY_LOOKS) {
119
+ const r = hallSuite({ UI_DESIGN_PACK: path.join(ROOT, 'modules', 'ui-design', 'styles', name, 'pack.json') });
120
+ assert.equal(r.status, 0, `${name}, tinted night:\n${r.fails.join('\n')}`);
121
+ }
122
+ });
123
+
124
+ test('the hall clears AA under custom accents round the hue wheel, once the platform has nudged them', () => {
125
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'look-tint-'));
126
+ const chrome = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'styles', 'chrome-world', 'pack.json'), 'utf8'));
127
+ const hsl = (h, s, l) => {
128
+ const a = s * Math.min(l, 1 - l), k = (n) => (n + h / 30) % 12;
129
+ const f = (n) => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))));
130
+ return '#' + [f(0), f(8), f(4)].map((x) => x.toString(16).padStart(2, '0')).join('');
131
+ };
132
+ // Saturated hues at three lightnesses, plus the greys — the corners of the space.
133
+ const raw = [];
134
+ for (let h = 0; h < 360; h += 45) for (const l of [0.25, 0.5, 0.8]) raw.push(hsl(h, 1, l));
135
+ raw.push('#000000', '#808080', '#ffffff');
136
+ const failures = [];
137
+ try {
138
+ for (const hex of raw) {
139
+ const kept = lookAdjustAccent(hex).hex;
140
+ const pack = structuredClone(chrome);
141
+ pack.theme.ui.colors.accent = kept;
142
+ const file = path.join(tmp, `${hex.slice(1)}.json`);
143
+ fs.writeFileSync(file, JSON.stringify(pack));
144
+ for (const tint of ['0', '1']) {
145
+ const r = hallSuite({ UI_DESIGN_PACK: file, UI_DESIGN_TINT: tint });
146
+ if (r.status !== 0) failures.push(`${hex} → ${kept} (${tint === '1' ? 'tinted' : 'black'} night): ${r.fails.join('; ') || 'suite failed'}`);
147
+ }
148
+ }
149
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
150
+ assert.deepEqual(failures, []);
151
+ });
152
+
153
+ test('UI_DESIGN_TINT is honoured, not decorative: a tint that is too strong fails the hall suite', () => {
154
+ // Proves the knob reaches the resolver: the same suite, with the card tinted far past
155
+ // the shipped share, must go red on the pair the 8% draft already failed.
156
+ const src = fs.readFileSync(path.join(ROOT, 'tests', 'hall_tokens.mjs'), 'utf8');
157
+ assert.match(src, /process\.env\.UI_DESIGN_TINT === '1'/);
158
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'look-tint-strong-'));
159
+ try {
160
+ const strong = path.join(tmp, 'night-tint.js');
161
+ fs.writeFileSync(strong, fs.readFileSync(path.join(ROOT, 'src', 'night-tint.js'), 'utf8').replace(/var\(--accent\) 5%, #101012/, 'var(--accent) 30%, #101012'));
162
+ const probe = path.join(tmp, 'hall_tokens.mjs');
163
+ fs.writeFileSync(probe, src.replace("'../src/night-tint.js'", JSON.stringify(strong)).replace(
164
+ "path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')", JSON.stringify(ROOT)));
165
+ const r = spawnSync(process.execPath, ['--test', '--test-reporter=tap', probe], { cwd: ROOT, encoding: 'utf8', env: childEnv({ UI_DESIGN_TINT: '1' }), timeout: 120000 });
166
+ assert.notEqual(r.status, 0, 'a 30% card tint must fail the hall suite');
167
+ assert.match(r.stdout, /dark --[\w-]+ on --[\w-]+ = \d/);
168
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
169
+ });
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
87
87
  // the file and left the query alone, so a returning browser kept the cached copy —
88
88
  // a fix that reaches nobody looks identical to a fix that does not work.
89
89
 
90
- const SHELL_SHA = 'c05e9d87594aca3c';
90
+ const SHELL_SHA = '51a13ea563542113';
91
91
 
92
92
  test('shell.js has not changed without its cache-bust being bumped', () => {
93
93
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
@@ -715,8 +715,9 @@ test('a renumbering of the wizard invalidates old drafts — the key carries the
715
715
  const panels = (SCRIPT.match(/var panels = \{([^}]*)\}/) || [, ''])[1];
716
716
  const count = [...panels.matchAll(/\d+:\s*'panel-/g)].length;
717
717
  // task 1004417 put the front door's two screens (terms, fork) in front: the sixth renumbering
718
- assert.equal(count, 12, 'gate + the front door\'s two + eight numbered steps + done — bump DRAFT_KEY when this changes');
719
- assert.equal(key, 'cb-create-draft-v6', 'and the key moved with the front door');
718
+ // task 1004421 put the Look step between App and Review: the seventh
719
+ assert.equal(count, 13, 'gate + the front door\'s two + nine numbered steps + done — bump DRAFT_KEY when this changes');
720
+ assert.equal(key, 'cb-create-draft-v7', 'and the key moved with the Look step');
720
721
  });
721
722
 
722
723
  test('every row lays the same five parts — the ledger has no ragged row', () => {
@@ -2137,7 +2138,7 @@ test('the wizard takes the world by scope: no card ground, the six-step strip as
2137
2138
  assert.match(block, /#view-new \.card\{background:none;border-radius:0;box-shadow:none;/, 'no card ground');
2138
2139
  // eight tracks since task 1004351 added the App step (seven since 1002339); the count is the
2139
2140
  // rail's own, so it moves with the panels map rather than being a magic number
2140
- assert.match(block, /#view-new \.steps\{display:grid;grid-template-columns:repeat\(8,1fr\);/, 'the strip lays a track per numbered step');
2141
+ assert.match(block, /#view-new \.steps\{display:grid;grid-template-columns:repeat\(9,1fr\);/, 'the strip lays a track per numbered step');
2141
2142
  assert.match(block, /#view-new \.steps li::before\{content:"";position:absolute;top:-4px;left:0;right:auto;width:7px;height:7px;/, 'the connector becomes the 7px dot');
2142
2143
  assert.match(block, /#view-new \.steps li\.now::before\{background:var\(--accent\);border-color:var\(--accent\);outline:4px solid/, 'the lit step carries the accent and an outline halo');
2143
2144
  assert.match(block, /#view-new \.panel h1\{font-weight:300;[^}]*text-transform:lowercase;/, 'the step title in the display voice, lowercase');
@@ -2184,7 +2185,7 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
2184
2185
  assert.match(block, /\.addrBig code\{display:block;font-family:var\(--font-display\);/, 'the address stands in the display voice');
2185
2186
  assert.doesNotMatch(block, /linear-gradient/, 'no tinted plates');
2186
2187
  assert.doesNotMatch(block, /box-shadow:(?!none)/, 'no box shadow in the wizard block');
2187
- assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="9">← Back<\/button>\s*<button type="button" class="btn big" id="doCreate">Create my project<\/button>/, 'the review\'s doors sit on one row, back then the primary');
2188
+ assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="10">← Back<\/button>\s*<button type="button" class="btn big" id="doCreate">Create my project<\/button>/, 'the review\'s doors sit on one row, back then the primary');
2188
2189
  assert.match(HUB, /<h2 class="stepsHead" id="doneStepsHead">Your steps — the parts only you can play<\/h2>/, 'the steps heading is the label face by class, not an inline style (the id is where a skipped invite step hands focus on, task 1002346)');
2189
2190
  // what is enqueued is unchanged (ADR 0111): the filing body the control plane reads
2190
2191
  assert.match(SCRIPT, /body: JSON\.stringify\(\{\s*slug: state\.slug,/, 'the filing still carries the slug first');
@@ -240,7 +240,7 @@ test('a repaint never lands on top of an open Retry — from this watch or a fre
240
240
  });
241
241
 
242
242
  test('both surfaces use it: the done panel and the project page', () => {
243
- assert.match(HUB, /if \(box\) watchApp\(instanceId, box, function \(\) \{ return state\.step === 11; \}, 5000\);/);
243
+ assert.match(HUB, /if \(box\) watchApp\(instanceId, box, function \(\) \{ return state\.step === 12; \}, 5000\);/);
244
244
  assert.match(HUB, /<div id="doneApp" hidden><\/div>/);
245
245
  assert.match(HUB, /<div class="card mSec" id="mAppCard" hidden>\n\s*<h2>Where its app runs<\/h2>\n\s*<div id="mApp"><\/div>/);
246
246
  assert.match(HUB, /loadAppStatus\(inst\.id \|\| asked, inst\);/);
@@ -142,16 +142,17 @@ test('the panel is real markup: Render and Decide later, Decide later the defaul
142
142
  assert.doesNotMatch(PANEL, /Coming soon/, 'and is no longer marked soon');
143
143
  assert.match(PANEL, /<input type="radio" name="appPlatform" value="later" checked>/, 'Decide later is the default');
144
144
  assert.match(PANEL, /We host your Builders' hall/, 'the step says the hall is ours to host (ADR 0345 D1), in the page\'s one spelling of it');
145
- assert.match(PANEL, /<form data-next="10">/, 'Next leads to the review');
145
+ assert.match(PANEL, /<form data-next="10">/, 'Next leads to the Look step (task 1004421), then the review');
146
146
  assert.match(PANEL, /data-back="8"/, 'Back returns to the address step');
147
147
  });
148
148
 
149
- test('the step sits between Address and Review in the panels map and on the rail', () => {
150
- assert.match(HUB, /8: 'panel-address', 9: 'panel-app', 10: 'panel-review', 11: 'panel-done'/);
149
+ test('the step sits between Address and the Look step in the panels map and on the rail', () => {
150
+ assert.match(HUB, /8: 'panel-address', 9: 'panel-app', 10: 'panel-look', 11: 'panel-review', 12: 'panel-done'/);
151
151
  assert.match(HUB, /<li data-step="9"><span class="snum">7<\/span><span class="slbl">App<\/span><\/li>/);
152
- assert.match(HUB, /<li data-step="10"><span class="snum">8<\/span><span class="slbl">Create<\/span><\/li>/);
153
- assert.match(HUB, /data-back="9">← Back<\/button>\n\s*<button type="button" class="btn big" id="doCreate">/, "the review's Back returns to the app step");
154
- assert.match(HUB, /if \(step === 9\) \{ syncAppStep\(\); syncRenderSetup\(\); \}\n\s*if \(step === 10\) renderReview\(\);/, 'entering the step repaints it, Render setup included');
152
+ assert.match(HUB, /<li data-step="10"><span class="snum">8<\/span><span class="slbl">Look<\/span><\/li>/);
153
+ assert.match(HUB, /<li data-step="11"><span class="snum">9<\/span><span class="slbl">Create<\/span><\/li>/);
154
+ assert.match(HUB, /data-back="10">← Back<\/button>\n\s*<button type="button" class="btn big" id="doCreate">/, "the review's Back returns to the Look step");
155
+ assert.match(HUB, /if \(step === 9\) \{ syncAppStep\(\); syncRenderSetup\(\); \}\n\s*if \(step === 10\) renderLook\(\);\n\s*if \(step === 11\) renderReview\(\);/, 'entering the step repaints it, Render setup included');
155
156
  assert.match(HUB, /if \(step === 9\) return validateRender\(\);/, 'Next on the step runs the Render check');
156
157
  });
157
158
 
@@ -455,5 +456,5 @@ test('the review names exactly what was chosen, prices included', () => {
455
456
  });
456
457
 
457
458
  test('Create hands the app to Render straight after the project is filed', () => {
458
- assert.match(HUB, /show\(11, true\);\n\s*sendRenderSetup\(data\.instance && data\.instance\.id\);/);
459
+ assert.match(HUB, /show\(12, true\);\n\s*sendRenderSetup\(data\.instance && data\.instance\.id\);/);
459
460
  });
@@ -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 {