@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
package/src/branding.js CHANGED
@@ -19,6 +19,7 @@
19
19
  const fs = require('node:fs');
20
20
  const path = require('node:path');
21
21
  const { resolveCoreRoot, resolveInstanceRoot, resolveEnv, FALLBACK_ENV_PREFIX } = require('./instance-config');
22
+ const { applyLook, readLibraryLook } = require('./hall-look');
22
23
 
23
24
  // ADR 0108 §1: the neutral starter ships WITH the core package; the instance
24
25
  // pack is host content. Both resolvers return the repo root today, so this is
@@ -83,6 +84,16 @@ const ENV_OVERRIDES = [
83
84
  // door is `open` and this is `none`. Like its neighbours it is memoized for the
84
85
  // process, which is why the platform restarts the instance to apply a change.
85
86
  ['SPEND_PAYER', ['project', 'spendPayer']],
87
+ // The look this hall wears (ADR 0358, task 1004421) — own | a style-library name |
88
+ // custom — and its night tint (on | off). Policy keys like their neighbours: the
89
+ // platform pushes them and a restart applies them. LOOK_ACCENT (a custom look's one
90
+ // accent, already made readable by the platform) and LOGO_URL (the platform's
91
+ // content-addressed logo) are their companions. src/hall-look.js turns them into
92
+ // the fifteen; an unknown or malformed value leaves the hall on its own pack.
93
+ ['LOOK', ['project', 'look']],
94
+ ['NIGHT_TINT', ['project', 'nightTint']],
95
+ ['LOOK_ACCENT', ['project', 'lookAccent']],
96
+ ['LOGO_URL', ['project', 'logoUrl']],
86
97
  ];
87
98
 
88
99
  const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
@@ -115,7 +126,7 @@ function deepFreeze(o) {
115
126
  // resolver. Decided in ADR 0142 (audit H11 / task 1002224).
116
127
  //
117
128
  // Pure resolver — all inputs injected, so tests drive it with zero I/O.
118
- function resolveBranding({ neutral = {}, instance = {}, env = {} } = {}) {
129
+ function resolveBranding({ neutral = {}, instance = {}, env = {}, readLook = readLibraryLook } = {}) {
119
130
  // structuredClone so the merged result shares no nested references with the
120
131
  // neutral/instance inputs — otherwise the deepFreeze below would also freeze
121
132
  // the caller's input objects (deepMerge shallow-copies only the top level).
@@ -142,6 +153,9 @@ function resolveBranding({ neutral = {}, instance = {}, env = {} } = {}) {
142
153
  if (missing.length) {
143
154
  throw new Error(`branding: missing required field(s): ${missing.map((p) => p.join('.')).join(', ')}`);
144
155
  }
156
+ // The chosen look, laid over the pack's theme (ADR 0358). A no-op for a hall that
157
+ // chose none, which is every hall the platform has not pushed a look to.
158
+ applyLook(resolved, readLook);
145
159
  return deepFreeze(resolved);
146
160
  }
147
161
 
@@ -240,6 +254,11 @@ function clientBranding(b = branding()) {
240
254
  },
241
255
  favicon: ui.favicon || '',
242
256
  backdrop: ui.backdrop || '',
257
+ // The look (ADR 0358): which one this hall wears ('' = its own pack), whether
258
+ // its night is tinted (only ever true with a look), and its logo address.
259
+ look: ui.look || '',
260
+ nightTint: ui.nightTint === true,
261
+ logo: ui.logo || '',
243
262
  },
244
263
  copy: {
245
264
  tagline: b.copy?.tagline || '',
@@ -282,6 +301,12 @@ function clientBranding(b = branding()) {
282
301
  // with?", not the platform's record of what they saved. The fallback is the
283
302
  // platform default, which for this key IS today's behaviour.
284
303
  spendPayer: b.project?.spendPayer || 'none',
304
+ // The look and its night (ADR 0358, task 1004421). Published for the read-back
305
+ // reason its neighbours are: the settings push compares this manifest with what
306
+ // the platform saved. The fallbacks are the platform defaults — 'own' is today's
307
+ // hall, and 'on' only ever tints a hall that wears a look.
308
+ look: b.project?.look || 'own',
309
+ nightTint: b.project?.nightTint || 'on',
285
310
  },
286
311
  };
287
312
  }
@@ -0,0 +1,99 @@
1
+ // src/hall-look.js — the look a hall wears, laid over its branding pack (ADR 0358,
2
+ // task 1004421).
3
+ //
4
+ // The platform chooses a project's look (provisioning settings `look`, `night_tint`
5
+ // plus the companion LOOK_ACCENT and LOGO_URL) and pushes only NAMES over web.env;
6
+ // src/branding.js maps them onto project.look / nightTint / lookAccent / logoUrl and
7
+ // hands the merged pack here. This file turns the name into the fifteen, reading the
8
+ // style library the core package ships (a module's styles/<name>/pack.json, ADR 0219),
9
+ // so a palette never travels as env text. The library is found by its SHAPE — any
10
+ // shipped module's styles/ directory — never by a module key, which core may not name
11
+ // (ADR 0083 §Decision #4).
12
+ //
13
+ // own (or absent) the instance's own pack — exactly today's hall.
14
+ // <library name> that look's thirteen colours and two faces.
15
+ // custom the chrome world with ONE accent, already made readable in both
16
+ // modes by the platform (lookAdjustAccent); a value that is not a
17
+ // plain #rrggbb is ignored and the hall stays on its own pack.
18
+ //
19
+ // Favicon and backdrop stay the instance's (ADR 0219 §1) except that a looked hall
20
+ // gets a favicon in its own colours, and a logo, when there is one, is the favicon.
21
+ // PURE given `readLook`; the default reader is the only I/O.
22
+ 'use strict';
23
+
24
+ const fs = require('node:fs');
25
+ const path = require('node:path');
26
+
27
+ const HEX = /^#[0-9a-f]{6}$/i;
28
+ const LOOK_NAME = /^[a-z][a-z-]{0,39}$/;
29
+ // A logo address is the platform's content-addressed image URL. https only, and no
30
+ // character that could end the attribute or the CSS it is written into.
31
+ const LOGO_URL = /^https:\/\/[^\s"'<>()\\]+$/;
32
+
33
+ // The style library's packs, read once each. null for an unknown name or a pack that
34
+ // does not carry the thirteen as hex — a broken file never half-applies.
35
+ const _looks = new Map();
36
+ function readLibraryLook(name, { root } = {}) {
37
+ if (!LOOK_NAME.test(String(name || ''))) return null;
38
+ const key = `${root || ''}|${name}`;
39
+ if (_looks.has(key)) return _looks.get(key);
40
+ let ui = null;
41
+ try {
42
+ const base = root || require('./instance-config').resolveCoreRoot(); // eslint-disable-line global-require
43
+ const modulesDir = path.join(base, 'modules');
44
+ const file = fs.readdirSync(modulesDir).sort()
45
+ .map((m) => path.join(modulesDir, m, 'styles', name, 'pack.json'))
46
+ .find((f) => fs.existsSync(f));
47
+ const pack = file ? JSON.parse(fs.readFileSync(file, 'utf8')) : null;
48
+ const t = pack && pack.theme && pack.theme.ui;
49
+ if (t && t.colors && Object.values(t.colors).every((v) => HEX.test(v)) && t.fonts) ui = { colors: t.colors, fonts: t.fonts };
50
+ } catch { ui = null; }
51
+ _looks.set(key, ui);
52
+ return ui;
53
+ }
54
+
55
+ // The favicon a looked hall shows without a logo: the chrome world's two-circle mark
56
+ // redrawn in the look's own accent, terracotta and card.
57
+ function lookFavicon(colors) {
58
+ const c = (k) => encodeURIComponent(colors[k]);
59
+ return "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>" +
60
+ `<circle cx='12' cy='17' r='8' fill='${c('accent')}'/><circle cx='22' cy='17' r='6' fill='${c('terracotta')}'/>` +
61
+ `<circle cx='12' cy='17' r='3.5' fill='${c('bgCard')}'/><circle cx='22' cy='17' r='2.5' fill='${c('bgCard')}'/></svg>`;
62
+ }
63
+
64
+ // What the hall wears: { name, ui } or null for its own pack. PURE given readLook.
65
+ function chosenLook(project = {}, readLook = readLibraryLook) {
66
+ const name = String(project.look || 'own');
67
+ if (name === 'own') return null;
68
+ if (name === 'custom') {
69
+ const accent = String(project.lookAccent || '');
70
+ const base = HEX.test(accent) ? readLook('chrome-world') : null;
71
+ return base ? { name, ui: { colors: { ...base.colors, accent: accent.toLowerCase() }, fonts: base.fonts } } : null;
72
+ }
73
+ const ui = readLook(name);
74
+ return ui ? { name, ui } : null;
75
+ }
76
+
77
+ // Lay the look over a resolved (still mutable) pack, in place. A logo applies with or
78
+ // without a look; the night tint only with one (ADR 0358 D2), so a hall that never
79
+ // chose a look renders exactly as before whatever its tint setting says.
80
+ function applyLook(resolved, readLook = readLibraryLook) {
81
+ const project = resolved.project || {};
82
+ const look = chosenLook(project, readLook);
83
+ const logo = LOGO_URL.test(String(project.logoUrl || '')) ? project.logoUrl : '';
84
+ if (!look && !logo) return resolved;
85
+ resolved.theme = resolved.theme && typeof resolved.theme === 'object' ? resolved.theme : {};
86
+ const ui = { ...(resolved.theme.ui || {}) };
87
+ if (look) {
88
+ ui.colors = { ...look.ui.colors };
89
+ ui.fonts = { ...look.ui.fonts };
90
+ ui.look = look.name;
91
+ ui.nightTint = project.nightTint !== 'off';
92
+ ui.favicon = lookFavicon(ui.colors);
93
+ }
94
+ if (logo) { ui.logo = logo; ui.favicon = logo; }
95
+ resolved.theme.ui = ui;
96
+ return resolved;
97
+ }
98
+
99
+ module.exports = { applyLook, readLibraryLook };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.58'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.60'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,26 @@
1
+ // src/night-tint.js — the night tint a hall wears with a look (ADR 0358, task 1004421).
2
+ //
3
+ // The hall's dark tier (modules/hall-ui/public/style.css) is pure black. A hall that
4
+ // wears a look takes a hint of that look's own accent into its four dark grounds
5
+ // instead, so two halls stay told apart at night too. It is a FORMULA over the
6
+ // thirteen, never a palette a look authors: no token is added, and a custom accent
7
+ // gets its own night for free. This table is the one copy — themeMarkup() emits it
8
+ // and tests/hall_tokens.mjs (UI_DESIGN_TINT=1) measures it.
9
+ 'use strict';
10
+
11
+ const NIGHT_TINT = Object.freeze({
12
+ '--bg': 'color-mix(in srgb, var(--accent) 6%, #000000)',
13
+ '--bg-deep': 'color-mix(in srgb, var(--accent) 6%, #000000)',
14
+ '--bg-frame': 'color-mix(in srgb, var(--accent) 6%, #08080a)',
15
+ '--bg-card': 'color-mix(in srgb, var(--accent) 5%, #101012)',
16
+ });
17
+
18
+ // The CSS that applies it: the same two dark twins the sheet writes (the [data-theme]
19
+ // switch shell.js owns, and the media block that covers first paint), at the same
20
+ // specificity. Injected AFTER the sheet, so later wins. PURE.
21
+ function nightTintCss() {
22
+ const decls = Object.entries(NIGHT_TINT).map(([k, v]) => `${k}:${v};`).join('');
23
+ return `html[data-theme="dark"]{${decls}}@media (prefers-color-scheme: dark){html:not([data-theme]){${decls}}}`;
24
+ }
25
+
26
+ module.exports = { NIGHT_TINT, nightTintCss };
@@ -35,7 +35,7 @@ api.pool = {
35
35
  async query(sql, params = []) {
36
36
  const q = flat(sql);
37
37
  seen.push({ q, params });
38
- if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) {
38
+ if (/FROM pt JOIN tasks t ON t\.id = pt\.id LEFT JOIN task_grades g/.test(q)) {
39
39
  assert.match(q, /WHERE t\.source = 'page-tweak'/);
40
40
  // The fixture rows stand in for this SELECT, so every column they carry
41
41
  // must be one the SELECT really names: a renamed alias would otherwise
@@ -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
  }
@@ -188,7 +188,7 @@ export function installPageLedger(api, { builders }) {
188
188
  return { rows: db.tasks.filter((t) => t.status === 'shipped' && Date.parse(t.shipped_at) > since && (t.touches || []).some((f) => p[0].includes(f))).map((t) => ({ id: t.id, title: t.title, shipped_at: t.shipped_at })) };
189
189
  }
190
190
  // The page reads (listPageTweakTasks), computed from the ledger's state.
191
- if (/FROM tasks t LEFT JOIN task_grades g/.test(q)) {
191
+ if (/FROM pt JOIN tasks t ON t\.id = pt\.id LEFT JOIN task_grades g/.test(q)) {
192
192
  assert.match(q, /WHERE t\.source = 'page-tweak'/);
193
193
  // The optional page scope (task 1004319): $1 is the round-ref prefix or null.
194
194
  assert.match(q, /left\(t\.source_ref, length\(\$1::text\)\) = \$1::text/);
@@ -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
+ });
@@ -0,0 +1,81 @@
1
+ // tests/main_discipline.mjs — the builder's own MAIN role (task 1004433 /
2
+ // WA6.RP03, core_268): what updateBuilderDisciplines sends for each of the
3
+ // three answers, and what it refuses before anything is written.
4
+ //
5
+ // The statement's own behaviour (keep, clear, set) runs against a real Postgres
6
+ // in tests/role_stats_db.mjs; this pins the half before it, DB-free: the
7
+ // "named or not" flag, the value, and the refusal of a main role that is not
8
+ // one of the builder's chosen crafts.
9
+
10
+ import { strict as assert } from 'node:assert';
11
+ import { createRequire } from 'node:module';
12
+
13
+ const require = createRequire(import.meta.url);
14
+
15
+ let passed = 0;
16
+ let failed = 0;
17
+ async function t(name, fn) {
18
+ try { await fn(); passed++; console.log(` ok ${name}`); }
19
+ catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
20
+ }
21
+
22
+ const calls = [];
23
+ const poolPath = require.resolve('../src/bongos/pool.js');
24
+ require.cache[poolPath] = {
25
+ id: poolPath, filename: poolPath, loaded: true,
26
+ exports: {
27
+ pool: {
28
+ async query(sql, params) {
29
+ calls.push({ sql, params });
30
+ return { rows: [{ id: params[0], preferred_disciplines: params[1], main_discipline: params[2] ? params[3] : null }] };
31
+ },
32
+ },
33
+ },
34
+ };
35
+ const db = require('../src/bongos/db.js');
36
+ const { enabledDisciplines } = require('../src/modules.js');
37
+ const offered = enabledDisciplines();
38
+ const last = () => calls[calls.length - 1];
39
+
40
+ await t('the write goes through the one exported statement', async () => {
41
+ await db.updateBuilderDisciplines(7, ['engineer']);
42
+ assert.equal(last().sql, db.UPDATE_BUILDER_DISCIPLINES_SQL);
43
+ assert.match(db.UPDATE_BUILDER_DISCIPLINES_SQL, /main_discipline = CASE WHEN \$3::boolean THEN \$4::text/);
44
+ assert.match(db.UPDATE_BUILDER_DISCIPLINES_SQL, /WHEN main_discipline = ANY\(\$2::text\[\]\) THEN main_discipline\s+ELSE NULL END/);
45
+ });
46
+
47
+ await t('not mentioned: the flag is false, so the stored main role is left to the statement to keep or clear', async () => {
48
+ await db.updateBuilderDisciplines(7, ['engineer', 'ideator']);
49
+ assert.deepEqual(last().params.slice(2), [false, null]);
50
+ });
51
+
52
+ await t('named: the flag is true and the role travels', async () => {
53
+ await db.updateBuilderDisciplines(7, ['ideator', 'engineer'], { mainDiscipline: 'ideator' });
54
+ assert.deepEqual(last().params.slice(2), [true, 'ideator']);
55
+ assert.deepEqual(last().params[1], offered.filter((d) => ['ideator', 'engineer'].includes(d)),
56
+ 'the set is still stored in canonical order: the main role is what carries the choice');
57
+ });
58
+
59
+ await t('null clears it on purpose', async () => {
60
+ await db.updateBuilderDisciplines(7, ['engineer'], { mainDiscipline: null });
61
+ assert.deepEqual(last().params.slice(2), [true, null]);
62
+ });
63
+
64
+ await t('a main role that is not one of the chosen crafts is refused, and nothing is written', async () => {
65
+ const before = calls.length;
66
+ await assert.rejects(() => db.updateBuilderDisciplines(7, ['engineer'], { mainDiscipline: 'ideator' }),
67
+ (err) => err.code === 'invalid_main_discipline' && err.bad[0] === 'ideator');
68
+ assert.equal(calls.length, before);
69
+ });
70
+
71
+ await t('the PATCH route hands main_discipline through only when the body names it', async () => {
72
+ const fs = require('node:fs');
73
+ const src = fs.readFileSync(require.resolve('../src/bongos/routes/me.js'), 'utf8');
74
+ const route = src.slice(src.indexOf("router.patch('/me/disciplines'"), src.indexOf("router.patch('/me/disciplines'") + 2500);
75
+ assert.match(route, /hasOwnProperty\.call\(body, 'main_discipline'\)/);
76
+ assert.match(route, /db\.updateBuilderDisciplines\(req\.builder\.id, input, mainOpt\)/);
77
+ assert.match(route, /invalid_main_discipline/);
78
+ });
79
+
80
+ console.log(`\n${passed} passed, ${failed} failed`);
81
+ if (failed) process.exit(1);
@@ -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);