@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,215 @@
1
+ // tests/autonomy_runner_identity.mjs — the runner card says which PC, which Claude
2
+ // account, which code, and whether the runner is live or restarting (task 1004407).
3
+ //
4
+ // Owner ruling, 2026-10-01: showing the code version is good, and showing when it
5
+ // is restarting versus live is better, along with which always-on PC and which
6
+ // Claude account it runs on. These pin each of those from the runner that reports
7
+ // them, through the route and the SQL, to the verdict the hall renders. The rule
8
+ // that holds across all of it: an unknown is shown as nothing, never as a guess,
9
+ // and a secret is never what gets sent.
10
+ //
11
+ // DB-free, no network. Run: node --test tests/autonomy_runner_identity.mjs
12
+
13
+ import { test } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import { createRequire } from 'node:module';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ const req = createRequire(import.meta.url);
22
+ const express = req('express');
23
+ const { attachFail } = req(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
24
+ const health = req(path.join(ROOT, 'modules/autonomy/runner-health.js'));
25
+ const runner = req(path.join(ROOT, 'scripts/gds/autobongos-run.js'));
26
+
27
+ const NOW = Date.parse('2026-10-01T12:00:00.000Z');
28
+ const agoS = (s) => new Date(NOW - s * 1000).toISOString();
29
+ const A = 'a'.repeat(40);
30
+ const B = 'b'.repeat(40);
31
+ const hb = (over = {}) => ({ host: 'gmk-always-on', mode: 'full', last_event: 'loop', last_seen_at: agoS(30), ...over });
32
+
33
+ // ── live or restarting ───────────────────────────────────────────────────────────
34
+
35
+ test('a runner that just exited to pick up new code reads RESTARTING, not dead', () => {
36
+ const d = health.describeRunner(hb({ last_event: 'upgrade_exit', last_seen_at: agoS(90) }), { nowMs: NOW });
37
+ assert.equal(d.state, 'restarting');
38
+ assert.equal(d.ok, true, 'a restart is not a fault');
39
+ assert.match(d.detail, /restarting on gmk-always-on to pick up new code/);
40
+ });
41
+
42
+ test('a restart that never came back is dead, and says it was a restart', () => {
43
+ const d = health.describeRunner(hb({ last_event: 'upgrade_exit', last_seen_at: agoS(health.RESTART_S + 60) }), { nowMs: NOW });
44
+ assert.equal(d.state, 'dead');
45
+ assert.equal(d.ok, false);
46
+ assert.match(d.detail, /exited on gmk-always-on .* to restart on new code and has not come back/);
47
+ });
48
+
49
+ test('the restart window ends exactly at RESTART_S', () => {
50
+ assert.equal(health.describeRunner(hb({ last_event: 'upgrade_exit', last_seen_at: agoS(health.RESTART_S) }), { nowMs: NOW }).state, 'restarting');
51
+ assert.equal(health.describeRunner(hb({ last_event: 'upgrade_exit', last_seen_at: agoS(health.RESTART_S + 1) }), { nowMs: NOW }).state, 'dead');
52
+ });
53
+
54
+ test('a live runner says what it last did, in words', () => {
55
+ assert.match(health.describeRunner(hb({ last_event: 'code_refreshed' }), { nowMs: NOW }).detail, /updated its code in place on gmk-always-on/);
56
+ assert.match(health.describeRunner(hb({ last_event: 'waiting' }), { nowMs: NOW }).detail, /waiting for work/);
57
+ assert.match(health.describeRunner(hb({ last_event: 'loop' }), { nowMs: NOW }).detail, /^running on/, 'an event with no words keeps the old wording');
58
+ assert.match(health.describeRunner(hb({ working_task_id: '4242', last_event: 'worked' }), { nowMs: NOW }).detail, /working task 4242/);
59
+ });
60
+
61
+ // ── which code ───────────────────────────────────────────────────────────────────
62
+
63
+ test('the code version is shown short, and flagged when it is behind main', () => {
64
+ const d = health.describeRunner(hb({ head: A, main_head: B }), { nowMs: NOW });
65
+ assert.equal(d.code.short, 'aaaaaaaa');
66
+ assert.equal(d.code.behind, true);
67
+ assert.equal(d.code.mainShort, 'bbbbbbbb');
68
+ assert.equal(health.describeRunner(hb({ head: A, main_head: A }), { nowMs: NOW }).code.behind, false);
69
+ });
70
+
71
+ test('an unknown main is never drawn as "out of date"', () => {
72
+ const c = health.describeRunner(hb({ head: A }), { nowMs: NOW }).code;
73
+ assert.equal(c.behind, null);
74
+ assert.equal(c.mainShort, null);
75
+ });
76
+
77
+ test('a checkout refreshed in place is current by what is on DISK', () => {
78
+ // Workers and child processes run what is on disk (task 1004406), so a
79
+ // refreshed runner whose own process is older is still up to date.
80
+ const c = health.describeRunner(hb({ head: A, disk_head: B, main_head: B }), { nowMs: NOW }).code;
81
+ assert.equal(c.behind, false);
82
+ assert.equal(c.diskShort, 'bbbbbbbb');
83
+ });
84
+
85
+ test('no head, or a head that is not a commit, shows no code line at all', () => {
86
+ for (const head of [null, undefined, '', 'abc', 'Z'.repeat(40), A.toUpperCase()]) {
87
+ assert.equal(health.describeRunner(hb({ head }), { nowMs: NOW }).code, null, String(head));
88
+ }
89
+ });
90
+
91
+ // ── which PC and which account ───────────────────────────────────────────────────
92
+
93
+ test('the PC and the Claude account ride on every state, dead included', () => {
94
+ for (const [over, state] of [[{}, 'alive'], [{ last_seen_at: agoS(health.LATE_S + 1) }, 'dead'], [{ last_event: 'upgrade_exit' }, 'restarting']]) {
95
+ const d = health.describeRunner(hb({ claude_account: 'me@example.com', head: A, ...over }), { nowMs: NOW });
96
+ assert.equal(d.state, state);
97
+ assert.equal(d.host, 'gmk-always-on');
98
+ assert.equal(d.account, 'me@example.com', `${state} must still say whose account it spent`);
99
+ assert.ok(d.code, `${state} must still say which code`);
100
+ }
101
+ });
102
+
103
+ test('the account label names a key or a token by its variable, never its value', () => {
104
+ const secret = 'sk-ant-SECRET-VALUE';
105
+ const viaKey = runner.claudeAccountLabel({ env: { ANTHROPIC_API_KEY: secret }, readFile: () => { throw new Error('unread'); } });
106
+ assert.equal(viaKey, 'an API key (ANTHROPIC_API_KEY)');
107
+ const viaToken = runner.claudeAccountLabel({ env: { CLAUDE_CODE_OAUTH_TOKEN: secret }, readFile: () => { throw new Error('unread'); } });
108
+ assert.equal(viaToken, 'a Claude token (CLAUDE_CODE_OAUTH_TOKEN)');
109
+ assert.ok(!viaKey.includes(secret) && !viaToken.includes(secret));
110
+ });
111
+
112
+ test('otherwise the label is the login Claude Code records, with its organisation', () => {
113
+ const seen = [];
114
+ const file = (o) => (p) => { seen.push(p); return JSON.stringify({ oauthAccount: o, primaryApiKey: 'never-read' }); };
115
+ assert.equal(runner.claudeAccountLabel({ env: { CLAUDE_CONFIG_DIR: '/cfg' }, readFile: file({ emailAddress: 'me@example.com', organizationName: 'Acme' }) }), 'me@example.com · Acme');
116
+ assert.equal(path.normalize(seen[0]), path.normalize('/cfg/.claude.json'), 'CLAUDE_CONFIG_DIR is where Claude Code keeps it when set');
117
+ assert.equal(runner.claudeAccountLabel({ env: {}, readFile: file({ emailAddress: 'me@example.com' }) }), 'me@example.com');
118
+ assert.equal(runner.claudeAccountLabel({ env: {}, readFile: file({}) }), null, 'no login reads as unknown');
119
+ assert.equal(runner.claudeAccountLabel({ env: {}, readFile: () => 'not json' }), null);
120
+ assert.equal(runner.claudeAccountLabel({ env: {}, readFile: () => { throw new Error('ENOENT'); } }), null);
121
+ });
122
+
123
+ // ── the runner sends it ──────────────────────────────────────────────────────────
124
+
125
+ test('every server check-in carries the code and the account, and the newest main seen', async () => {
126
+ const bodies = [];
127
+ const api = { autonomy: { postAutonomyHeartbeat: async ({ body }) => { bodies.push(body); return { ok: true }; } } };
128
+ const state = runner.newRunnerState();
129
+ // codeDrifted is what learns main's tip; it must keep it for the heartbeat.
130
+ await runner.codeDrifted({ loadedHead: async () => A, run: async () => ({ ok: true, stdout: `${B}\trefs/heads/main\n` }), state });
131
+ assert.equal(state.mainHead, B);
132
+ await runner.loadedHead({ run: async () => ({ ok: true, stdout: `${A}\n` }) });
133
+ await runner.postHeartbeat({ last_event: 'loop' }, { api, state, claudeAccountLabel: () => 'me@example.com' });
134
+ assert.equal(bodies[0].head, A);
135
+ assert.equal(bodies[0].main_head, B);
136
+ assert.equal(bodies[0].claude_account, 'me@example.com');
137
+ assert.equal(bodies[0].last_event, 'loop');
138
+ assert.equal('disk_head' in bodies[0], false, 'no refresh in place, so no disk_head');
139
+
140
+ state.diskHead = B;
141
+ await runner.postHeartbeat({}, { api, state, claudeAccountLabel: () => null });
142
+ assert.equal(bodies[1].disk_head, B);
143
+ assert.equal('claude_account' in bodies[1], false, 'an unknown account is left out, not sent empty');
144
+ });
145
+
146
+ // ── the route and the SQL ────────────────────────────────────────────────────────
147
+
148
+ function autonomyRouter() {
149
+ const api = req(path.join(ROOT, 'src/module-api.js'));
150
+ const saved = { b: api.requireBuilder, p: api.requirePermission };
151
+ api.requireBuilder = (r, _s, next) => { r.builder = { id: 3, rank: 'archon' }; next(); };
152
+ api.requirePermission = () => (_r, _s, next) => next();
153
+ const routePath = req.resolve(path.join(ROOT, 'modules/autonomy/routes/autonomy.js'));
154
+ delete req.cache[routePath];
155
+ try { return req(routePath)(); } finally { api.requireBuilder = saved.b; api.requirePermission = saved.p; }
156
+ }
157
+
158
+ test('the route stores a commit only if it is one, and the account label as sent', async () => {
159
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
160
+ const saved = db.recordRunnerHeartbeat;
161
+ const seen = [];
162
+ db.recordRunnerHeartbeat = async (a) => { seen.push(a); return {}; };
163
+ const app = express(); app.use(express.json()); app.use(attachFail); app.use(autonomyRouter());
164
+ const server = app.listen(0); await new Promise((r) => server.once('listening', r));
165
+ const post = (body) => fetch(`http://127.0.0.1:${server.address().port}/autonomy/heartbeat`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
166
+ try {
167
+ let r = await post({ host: 'gmk-always-on', head: A, disk_head: B, main_head: B, claude_account: ' me@example.com ' });
168
+ assert.equal(r.status, 200);
169
+ assert.deepEqual([seen[0].head, seen[0].diskHead, seen[0].mainHead, seen[0].claudeAccount], [A, B, B, 'me@example.com']);
170
+ r = await post({ host: 'gmk-always-on', head: 'not-a-commit', main_head: A.toUpperCase(), claude_account: '' });
171
+ assert.equal(r.status, 200, 'a bad version never costs the runner its check-in');
172
+ assert.deepEqual([seen[1].head, seen[1].mainHead, seen[1].claudeAccount], [null, null, null]);
173
+ r = await post({ host: 'gmk-always-on', claude_account: 'x'.repeat(201) });
174
+ assert.equal(r.status, 400, 'the label is capped, so a key pasted into it by mistake is refused, not stored');
175
+ } finally {
176
+ server.close();
177
+ db.recordRunnerHeartbeat = saved;
178
+ }
179
+ });
180
+
181
+ test('the heartbeat SQL overwrites the identity every beat and reads it back', async () => {
182
+ const api = req(path.join(ROOT, 'src/module-api.js'));
183
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
184
+ const saved = api.pool.query;
185
+ const calls = [];
186
+ api.pool.query = async (sql, params) => { calls.push({ sql, params }); return { rows: [{ head: A }] }; };
187
+ try {
188
+ await db.recordRunnerHeartbeat({ builderId: 3, host: 'gmk-always-on', head: A, diskHead: B, mainHead: B, claudeAccount: 'me@example.com' });
189
+ for (const col of ['head', 'disk_head', 'main_head', 'claude_account']) {
190
+ assert.match(calls[0].sql, new RegExp(`\\b${col} = EXCLUDED\\.${col}\\b`), `${col} is overwritten, never kept from the last process`);
191
+ }
192
+ assert.deepEqual(calls[0].params.slice(9), [A, B, B, 'me@example.com']);
193
+ await db.readRunnerHeartbeats(3);
194
+ assert.match(calls[1].sql, /head, disk_head, main_head, claude_account/);
195
+ } finally {
196
+ api.pool.query = saved;
197
+ }
198
+ });
199
+
200
+ test('the migration adds the four columns, additively', () => {
201
+ const sql = fs.readFileSync(path.join(ROOT, 'modules/autonomy/migrations/autonomy_004_runner_identity.sql'), 'utf8')
202
+ .split('\n').filter((l) => !l.trim().startsWith('--')).join('\n');
203
+ for (const col of ['head', 'disk_head', 'main_head', 'claude_account']) {
204
+ assert.match(sql, new RegExp(`ADD COLUMN IF NOT EXISTS ${col} text`));
205
+ }
206
+ assert.doesNotMatch(sql, /\bDROP\b/i);
207
+ });
208
+
209
+ test('the gate card draws the PC, the account, the code and the restarting label', () => {
210
+ const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.js'), 'utf8');
211
+ assert.match(src, /restarting: 'Restarting'/);
212
+ assert.match(src, /PC <strong>\$\{escapeHtml\(h\.host\)\}<\/strong>/);
213
+ assert.match(src, /Claude account \$\{escapeHtml\(h\.account\)\}/);
214
+ assert.match(src, /behind main/);
215
+ });
@@ -59,11 +59,14 @@ test('clientBranding exposes project policy — the settings read-back (task 100
59
59
  // is published for a reason of its own: it is the one key that can REFUSE a
60
60
  // module, so an owner looking at a module that will not turn on needs the
61
61
  // instance's own answer rather than the platform's record of what they saved.
62
+ // `look` and `nightTint` joined in task 1004421 (ADR 0358) — the look the hall wears
63
+ // and whether its night takes a tint. Public because the hall paints them in the
64
+ // browser; their fallbacks ('own', 'on') are what an unconfigured hall shows today.
62
65
  assert.deepEqual(clientBranding({}).project,
63
- { platformVisibility: 'public', joinability: 'apply', visibility: 'public', joinGrant: 'full', artistGate: 'strict', spendPayer: 'none' });
66
+ { platformVisibility: 'public', joinability: 'apply', visibility: 'public', joinGrant: 'full', artistGate: 'strict', spendPayer: 'none', look: 'own', nightTint: 'on' });
64
67
  assert.deepEqual(
65
- clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth', joinGrant: 'view', artistGate: 'off', spendPayer: 'builder' } }).project,
66
- { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth', joinGrant: 'view', artistGate: 'off', spendPayer: 'builder' },
68
+ clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth', joinGrant: 'view', artistGate: 'off', spendPayer: 'builder', look: 'grove', nightTint: 'off' } }).project,
69
+ { platformVisibility: 'gated', joinability: 'invite_only', visibility: 'stealth', joinGrant: 'view', artistGate: 'off', spendPayer: 'builder', look: 'grove', nightTint: 'off' },
67
70
  'what the instance actually resolved (env override included) is what it publishes');
68
71
  });
69
72
 
@@ -86,7 +89,7 @@ test('clientBranding is a SMALL allow-list — no server-only field leaks to the
86
89
  // public for the same read-back reason as its neighbours. Its companion stamp
87
90
  // (artistGateSince) deliberately does NOT: that is platform bookkeeping the server
88
91
  // reads, and no browser surface has a question it answers.
89
- assert.deepEqual(Object.keys(projected.project).sort(), ['artistGate', 'joinGrant', 'joinability', 'platformVisibility', 'spendPayer', 'visibility']);
92
+ assert.deepEqual(Object.keys(projected.project).sort(), ['artistGate', 'joinGrant', 'joinability', 'look', 'nightTint', 'platformVisibility', 'spendPayer', 'visibility']);
90
93
  for (const leak of ['domains', 'repo', 'db', 'envPrefix', 'llmProjectName']) {
91
94
  assert.equal(projected[leak], undefined, `${leak} must not reach the client`);
92
95
  }
@@ -41,6 +41,7 @@ import assert from 'node:assert/strict';
41
41
  import fs from 'node:fs';
42
42
  import path from 'node:path';
43
43
  import { fileURLToPath } from 'node:url';
44
+ import { createRequire } from 'node:module';
44
45
 
45
46
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
46
47
  const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
@@ -102,9 +103,17 @@ const PACK = Object.fromEntries(
102
103
  Object.entries(packUi.colors).map(([k, v]) => [camelToVar(k), v]),
103
104
  );
104
105
 
106
+ // UI_DESIGN_TINT=1 measures the NIGHT TINT a hall wears with a look (ADR 0358, task
107
+ // 1004421): themeMarkup() lays src/night-tint.js's four grounds over the dark tier,
108
+ // after the sheet, so they win. tests/look_night_tint.mjs runs this file once per look
109
+ // with it set, and once per custom accent round the hue wheel.
110
+ const NIGHT_TINT = process.env.UI_DESIGN_TINT === '1'
111
+ ? createRequire(import.meta.url)('../src/night-tint.js').NIGHT_TINT
112
+ : {};
113
+
105
114
  const RAW = {
106
115
  light: { ...contractTier, ...PACK },
107
- dark: { ...contractTier, ...PACK, ...darkExplicit },
116
+ dark: { ...contractTier, ...PACK, ...darkExplicit, ...NIGHT_TINT },
108
117
  };
109
118
 
110
119
  // ── color resolution ─────────────────────────────────────────────────────────
@@ -635,7 +635,7 @@ try {
635
635
  assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
636
636
  assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
637
637
  assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
638
- assert.match(HTML, /shell\.js\?v=2026-09-30-navback2/);
638
+ assert.match(HTML, /shell\.js\?v=2026-10-01-look/);
639
639
  assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
640
640
  for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-open-more', 'tw-divider', 'tw-openwrap', 'tw-open-menu', 'tw-open-tab', 'tw-open-split', 'tw-frame', 'tw-frame-view', 'tw-split-close', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks', 'tw-refused', 'tw-refused-title', 'tw-refused-list', 'tw-refused-next', 'tw-refused-download', 'tw-refused-close']) {
641
641
  assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);
@@ -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
  });