@bongos/core 1.24.57 → 1.24.59

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 (56) hide show
  1. package/.bongos-core.json +77 -67
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +0 -2
  4. package/clients/bongos-client/index.cjs +0 -2
  5. package/clients/bongos-client/index.d.ts +1 -3
  6. package/clients/bongos-client/index.mjs +0 -2
  7. package/docs/adr/0398-every-hosted-project-takes-its-own-update-and-nobody-asks-for-one.md +45 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +1 -37
  10. package/docs/api-reference.md +2 -3
  11. package/docs/architecture.md +1 -1
  12. package/docs/copy-inventory.md +38 -44
  13. package/docs/copy-registry.json +44 -108
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-readings.json +5 -5
  16. package/docs/recipes/ops-gotchas.md +2 -2
  17. package/modules/hall-ui/public/deploy-handoff.js +11 -3
  18. package/modules/hall-ui/public/deploy.js +10 -3
  19. package/modules/hall-ui/public/settings-software-update.js +4 -71
  20. package/modules/hall-ui/public/update-pill.js +6 -31
  21. package/modules/status-ui/public/style.css +5 -2
  22. package/modules/ui-design/public/brand/png-sources.json +10 -0
  23. package/modules/ui-design/public/brand.css +6 -0
  24. package/modules/ui-design/public/brand.html +2 -2
  25. package/modules/ui-design/scripts/gen-design-reference.js +2 -3
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/public-docs/index.html +3 -1
  29. package/release-notes.json +16 -0
  30. package/scripts/bongos/design-generators-fresh.js +14 -6
  31. package/scripts/bongos/gen-brand.js +82 -36
  32. package/scripts/bongos/provision-core-upgrade.js +30 -1
  33. package/scripts/bongos/provision-units.js +18 -0
  34. package/scripts/hall-preview/server.js +0 -3
  35. package/scripts/status-mirror-sync.js +4 -1
  36. package/src/bongos/routes/auth.js +4 -9
  37. package/src/bongos/routes/core-update.js +36 -76
  38. package/src/bongos/routes/feedback.js +1 -2
  39. package/src/bongos/serve-internal.js +5 -70
  40. package/src/bongos/sign-in-pages.js +20 -4
  41. package/src/brand-mark.js +10 -1
  42. package/src/error-page.js +11 -10
  43. package/src/module-api.js +1 -1
  44. package/src/theme-markup.js +81 -0
  45. package/tests/brand_mark.mjs +11 -1
  46. package/tests/brand_surfaces.mjs +5 -2
  47. package/tests/deploy_handoff.mjs +23 -2
  48. package/tests/deploy_page_projects.mjs +35 -3
  49. package/tests/design_counts_ratchet.mjs +38 -0
  50. package/tests/design_reference.mjs +1 -1
  51. package/tests/provision_settings_apply.mjs +52 -0
  52. package/tests/software_update.mjs +35 -5
  53. package/tests/update_pill.mjs +9 -5
  54. package/tests/wordmark_and_faces.mjs +111 -0
  55. package/src/bongos/upgrade-request.js +0 -158
  56. package/tests/upgrade_request.mjs +0 -348
@@ -447,9 +447,27 @@ function hubUpgradeEnvVarsFor(inst) {
447
447
  return { [`${provisionedEnvPrefix()}_HUB_UPGRADE_URL`]: origin ? `${origin}/deploy?project=${encodeURIComponent(String(inst.slug))}` : '' };
448
448
  }
449
449
 
450
+ // THE BACKFILL (task 1005128). Only a settings-apply wrote that line, and one runs only when a
451
+ // setting changes, so a project provisioned before task 1004608 (toast) never got it and its
452
+ // owner was offered "Ask for this upgrade" instead of the Update button. Every core move now
453
+ // carries the line too: the web.env as it stands, with that ONE line set, or null when it is
454
+ // already right, there is nothing to write, or the file is empty (a missing file is never
455
+ // "patched" into a one-line body). PURE; the core-upgrade leg does the read and the write.
456
+ function hubUpgradeEnvPatch(body, inst) {
457
+ const vars = hubUpgradeEnvVarsFor(inst);
458
+ const [key] = Object.keys(vars);
459
+ const text = String(body == null ? '' : body);
460
+ if (!key || !vars[key] || !text.trim()) return null;
461
+ const esc = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
462
+ const have = new RegExp(`^${esc}=(.*)$`, 'm').exec(text);
463
+ if (have && have[1].trim() === vars[key]) return null;
464
+ return upsertEnvVars(text, vars);
465
+ }
466
+
450
467
  module.exports = {
451
468
  federationHubOrigin,
452
469
  hubUpgradeEnvVarsFor,
470
+ hubUpgradeEnvPatch,
453
471
  ENV_PREFIX_PACK,
454
472
  envPrefixMismatch,
455
473
  normalizePackEnvPrefix,
@@ -208,9 +208,6 @@ const CANNED_WRITES = [
208
208
  // a vote cast from the street's matter card (task 1004528), so the card's
209
209
  // answer (the toast, the building lighting) renders here too.
210
210
  ['POST', /^government\/board\/items\/\d+\/votes$/, 'government__board__items__votes.post'],
211
- // Settings → Software update's "Ask for this upgrade" (task 1004595), so the
212
- // pending line the panel draws after the press renders here too.
213
- ['POST', /^software-update\/request$/, 'software-update__request.post'],
214
211
  // the studio's AI pages (task 1004921): a file uploads, a request is sent
215
212
  // (it answers as 1002, the running one), and money is added.
216
213
  ['POST', /^ai-studio\/uploads$/, 'ai-studio__uploads.post'],
@@ -12,7 +12,10 @@
12
12
  // outage resilience (ADR 0029) from a SEPARATE repo. Before this script there
13
13
  // was no sync, so every change to the status surface silently missed the live page
14
14
  // (that's how #726's reorg + #731's theming never showed up). This makes the
15
- // publish deterministic and CI-driven.
15
+ // publish deterministic. It is NOT CI-driven: no workflow in .github/workflows/ runs
16
+ // it (there is no sync-status-mirror.yml in this repo), so a status-surface change reaches the
17
+ // off-box page only when someone runs this by hand with a mirror checkout — see USAGE
18
+ // and docs/recipes/ops-gotchas.md (task 1005131 / task 1005133).
16
19
  //
17
20
  // CONFIG (task 1002510 — no host identity is baked in here; ADR 0062 §3):
18
21
  // domains.publicOrigin — the origin the mirror calls cross-origin
@@ -51,7 +51,7 @@ const { escapeHtml } = require('../../html-escape'); // the one server escaper (
51
51
  // the browser-approved device flow the CLI gets when Google is offered.
52
52
  const { googleConfigured } = require('../auth-google');
53
53
  const { startBrowserDeviceFlow, isBrowserDeviceCode, pollBrowserDeviceFlow } = require('../device-approval');
54
- const { renderSignInChooser } = require('../sign-in-pages');
54
+ const { renderSignInChooser, signInHead } = require('../sign-in-pages');
55
55
 
56
56
  // Render a small standalone HTML page in the builders' hall palette. Used for
57
57
  // OAuth error branches so the visitor sees something coherent rather than a
@@ -103,10 +103,7 @@ function renderAuthErrorPage(heading, detail, opts = {}) {
103
103
  <meta charset="utf-8">
104
104
  <meta name="viewport" content="width=device-width, initial-scale=1">
105
105
  <title>${escapeHtml(worldName)} — ${escapeHtml(copy.titleSuffix)}</title>
106
- <link rel="preconnect" href="https://fonts.googleapis.com">
107
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
108
- <link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=EB+Garamond:ital,wght@0,400;0,500&display=swap" rel="stylesheet">
109
- <link rel="stylesheet" href="/builders/style.css">
106
+ ${signInHead()}
110
107
  </head><body>
111
108
  <header class="hall">
112
109
  <p class="hall__epigraph">${escapeHtml(copy.epigraph)}</p>
@@ -167,10 +164,7 @@ function renderAccessPendingPage(login, startHref) {
167
164
  <meta charset="utf-8">
168
165
  <meta name="viewport" content="width=device-width, initial-scale=1">
169
166
  <title>${escapeHtml(worldName)} — confirming access</title>
170
- <link rel="preconnect" href="https://fonts.googleapis.com">
171
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
172
- <link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=EB+Garamond:ital,wght@0,400;0,500&display=swap" rel="stylesheet">
173
- <link rel="stylesheet" href="/builders/style.css">
167
+ ${signInHead()}
174
168
  </head><body>
175
169
  <header class="hall">
176
170
  <p class="hall__epigraph">${escapeHtml(epigraph)}</p>
@@ -1494,6 +1488,7 @@ module.exports.webRedirectOrigin = () => WEB_REDIRECT_ORIGIN;
1494
1488
  // The auth-error page renderer (task 1439): pure over the branding pack, so a
1495
1489
  // test can prove every visible string is brand-owned (no OTB lore baked in).
1496
1490
  module.exports.renderAuthErrorPage = renderAuthErrorPage;
1491
+ module.exports.renderAccessPendingPage = renderAccessPendingPage;
1497
1492
  // The admitting-rank label for per-branch strings (task 1002616) — exported for
1498
1493
  // the same reason: the test proves the label is pack-owned, never "Archon".
1499
1494
  module.exports.admitterLabel = admitterLabel;
@@ -34,7 +34,6 @@ const { readSoftwareUpdate } = require('../software-update');
34
34
  const { followForShape } = require('../../../scripts/bongos/update-channel.js');
35
35
  const seams = require('../../module-seams');
36
36
  const { loadIdpConfig } = require('../auth-config');
37
- const upgradeRequest = require('../upgrade-request');
38
37
  const { readUpgradeHold } = require('../upgrade-hold');
39
38
  const { moveNotice, moveHistory, readInstalledReleaseNotes } = require('../core-moves');
40
39
 
@@ -82,10 +81,35 @@ function hubManageLinks(config) {
82
81
  // address as <PREFIX>_HUB_UPGRADE_URL (task 1004608). Without it, those owners were told to
83
82
  // run `bongos upgrade` on a server the platform runs. Only the hub's /deploy path is taken.
84
83
  const HUB_UPGRADE_URL_RE = /^https?:\/\/[^\s"'<>/]+\/deploy\?project=[A-Za-z0-9._%-]+$/;
85
- function updateDoor({ provisioning, idp, hubUpgradeUrl }) {
84
+ // THE LAST FALLBACK (task 1005128). A hosted project whose web.env predates the line above
85
+ // (toast, 2026-11) named neither, fell to `cli`, and its owner was offered "Ask for this
86
+ // upgrade" instead of the Update button — the owner: "if the update is generally available
87
+ // then they should be able to download it". So a project that answers on a name under its
88
+ // hub's own domain (the hub's rule for "a project we host") is sent to that hub's door by its
89
+ // address instead of its slug: `<hub>/deploy?host=<host>`. The hub resolves the address among
90
+ // the visitor's OWN projects, exactly as it resolves a slug, and the hand-off still only
91
+ // accepts the project's registered origin. A self-hosted instance on its own domain stays
92
+ // `cli`: nobody at the hub can reach its server.
93
+ function projectHost(raw) {
94
+ const host = String(raw || '').trim().toLowerCase();
95
+ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) return null;
96
+ if (/^\d+(\.\d+)+$/.test(host)) return null;
97
+ if (/(^|\.)(localhost|local|internal|test|invalid)$/.test(host)) return null;
98
+ return host;
99
+ }
100
+ function hubHostedDoor({ hub, host }) {
101
+ const h = projectHost(host);
102
+ let origin = null;
103
+ let hubHost = null;
104
+ try { const u = new URL(String(hub)); if (/^https?:$/.test(u.protocol)) { origin = u.origin; hubHost = u.host.toLowerCase(); } } catch { /* no hub */ }
105
+ if (!h || !origin || h === hubHost || !h.endsWith(`.${hubHost}`)) return null;
106
+ return `${origin}/deploy?host=${encodeURIComponent(h)}`;
107
+ }
108
+ function updateDoor({ provisioning, idp, hubUpgradeUrl, hub = null, host = null }) {
86
109
  if (provisioning) return { kind: 'local', url: '/deploy' };
87
110
  const url = hubDeployUrl(idp)
88
- || (typeof hubUpgradeUrl === 'string' && HUB_UPGRADE_URL_RE.test(hubUpgradeUrl.trim()) ? hubUpgradeUrl.trim() : null);
111
+ || (typeof hubUpgradeUrl === 'string' && HUB_UPGRADE_URL_RE.test(hubUpgradeUrl.trim()) ? hubUpgradeUrl.trim() : null)
112
+ || hubHostedDoor({ hub, host });
89
113
  return url ? { kind: 'hub', url } : { kind: 'cli', url: null };
90
114
  }
91
115
 
@@ -187,25 +211,15 @@ async function resolveChannel(selfChannel) {
187
211
  return { channel, source: channel ? 'deploy' : 'default' };
188
212
  }
189
213
 
190
- // "Ask for this upgrade" (task 1004595): what src/bongos/upgrade-request.js needs from
191
- // the outside world, resolved per request so a test can stand every piece in. The receipt
192
- // goes into the SAME store the public redeem route reads (routes/feedback.js).
193
- function defaultAskDeps() {
194
- const ps = require('../project-settings');
195
- const fb = require('./feedback');
196
- return {
197
- hub: fb.feedbackHub(),
198
- host: configuredHost(),
199
- getSetting: ps.getSetting,
200
- setSetting: ps.setSetting,
201
- issueReceipt: (r) => fb.defaultReceiptStore().issue(r),
202
- };
214
+ // The hub this project belongs to: the same answer the direct feedback door uses (null on the
215
+ // hub itself, so the platform never points at itself).
216
+ function safeHub() {
217
+ try { return require('./feedback').feedbackHub(); } catch { return null; }
203
218
  }
204
219
 
205
220
  // The host this project answers on, from its OWN branding pack (domains is server-only):
206
- // the origin the hub calls back to redeem the receipt. Never read from the request: a
207
- // Host or X-Forwarded-Host header is the caller's to write, and would let them point the
208
- // hub's call-back at any address they like. '' when the pack names none.
221
+ // what decides whether the hub hosts it (hubHostedDoor). Never read from the request: a
222
+ // Host or X-Forwarded-Host header is the caller's to write. '' when the pack names none.
209
223
  function configuredHost() {
210
224
  try {
211
225
  const d = require('../../branding').branding().domains || {};
@@ -213,18 +227,6 @@ function configuredHost() {
213
227
  } catch { return ''; }
214
228
  }
215
229
 
216
- // The page's ask state. Fail-soft: a stored row that cannot be read is "not asked yet",
217
- // never a 500 on the whole Software update panel.
218
- async function readAsk({ update, door, deps }) {
219
- const offered = update && update.reading === 'ok' ? update.offered : null;
220
- const base = { offered, host: upgradeRequest.projectHost(deps.host), doorKind: door.kind, hub: deps.hub };
221
- let stored = null;
222
- if (offered && upgradeRequest.isOperatorRun(base)) {
223
- try { stored = upgradeRequest.parseStored(await deps.getSetting(upgradeRequest.SETTING_KEY)); } catch { stored = null; }
224
- }
225
- return upgradeRequest.askState({ ...base, stored });
226
- }
227
-
228
230
  // A core move this project's own ledger says was rolled back (task 1004730,
229
231
  // src/bongos/upgrade-hold.js): which version was tried, why in plain words, and that this
230
232
  // project is still on its old version and working. Both reads carry it, the Software update
@@ -270,16 +272,13 @@ function buildCoreUpdateRouter({
270
272
  upgrades = readCoreUpgrades,
271
273
  softwareUpdate = readSoftwareUpdate,
272
274
  canUpdate = holdsCorePin,
273
- doorInputs = () => ({ provisioning: provisioningEnabled(), idp: safeIdpConfig(), hubUpgradeUrl: safeHubUpgradeUrl() }),
274
- askDeps = defaultAskDeps,
275
+ doorInputs = () => ({ provisioning: provisioningEnabled(), idp: safeIdpConfig(), hubUpgradeUrl: safeHubUpgradeUrl(), hub: safeHub(), host: configuredHost() }),
275
276
  upgradeHold = readUpgradeHold,
276
277
  releaseNotes = readInstalledReleaseNotes,
277
278
  seenMove = defaultSeenMove,
278
279
  } = {}) {
279
280
  contributeCoreUpdateToDocket();
280
281
  const router = express.Router();
281
- // One send at a time: a double press waits for the first and is answered from its row.
282
- let asking = null;
283
282
 
284
283
  router.get(
285
284
  '/core-update',
@@ -341,53 +340,13 @@ function buildCoreUpdateRouter({
341
340
  resolveChannel(selfChannel).then(async ({ channel, source }) => ({ ...(await softwareUpdate({ running, channel, follow: followFor(door) })), channel_source: source })),
342
341
  canUpdate(req),
343
342
  ]);
344
- const ask = await readAsk({ update, door, deps: askDeps() });
345
343
  const held = await safeHold(upgradeHold, { running, offered: update && update.reading === 'ok' ? update.offered : null });
346
344
  // The history only for a viewer who holds core.pin.move (see safeMoved's gate note).
347
345
  const history = can === true ? await safeHistory(upgrades) : null;
348
- res.json({ ok: true, update, door, can_update: can === true, ask, held, history });
346
+ res.json({ ok: true, update, door, can_update: can === true, held, history });
349
347
  }, { errorCode: 'software_update_failed', message: false }),
350
348
  );
351
349
 
352
- // POST /software-update/request — "Ask for this upgrade" (task 1004595). On a project
353
- // the hub's operator runs, sends one request for the version this project is offered
354
- // to the operator, through the direct feedback door, and remembers it so the page can
355
- // say it is pending. The version is the server's own reading, never the caller's.
356
- // Idempotent per version; a different version waits out a 24h cooldown (429).
357
- // rank: core.pin.move — the same atom as the Update button the panel draws.
358
- router.post(
359
- '/software-update/request',
360
- auth.requireBuilder,
361
- auth.requirePermission('core.pin.move'),
362
- asyncHandler('POST /software-update/request', async (req, res) => {
363
- while (asking) { try { await asking; } catch { /* the next one tries for itself */ } }
364
- const run = (async () => {
365
- let running = null;
366
- try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
367
- const { channel } = await resolveChannel(selfChannel);
368
- const door = updateDoor(doorInputs());
369
- const update = await softwareUpdate({ running, channel, follow: followFor(door) });
370
- const deps = askDeps();
371
- return upgradeRequest.requestUpgrade({
372
- host: deps.host,
373
- running,
374
- wanted: update && update.reading === 'ok' ? update.offered : null,
375
- builder: req.builder,
376
- doorKind: door.kind,
377
- }, deps);
378
- })();
379
- asking = run;
380
- let out;
381
- try { out = await run; } finally { if (asking === run) asking = null; }
382
- if (out.status >= 400) {
383
- const { error, message, ...details } = out.body;
384
- if (details.retry_after_seconds) res.set('Retry-After', String(details.retry_after_seconds));
385
- return res.fail(error, { status: out.status, message, details });
386
- }
387
- res.status(out.status).json(out.body);
388
- }, { errorCode: 'upgrade_request_failed', message: false }),
389
- );
390
-
391
350
  // GET /project/manage-link — the hub's page for THIS project, where its owner takes it
392
351
  // offline or disconnects it (task 1004570). On a hosted project the owner is its Archon,
393
352
  // so the read is Archon-only: a builder below that is refused and the hall draws no
@@ -410,4 +369,5 @@ module.exports = buildCoreUpdateRouter;
410
369
  module.exports.hubManageLinks = hubManageLinks;
411
370
  module.exports.hubDeployUrl = hubDeployUrl;
412
371
  module.exports.updateDoor = updateDoor;
372
+ module.exports.hubHostedDoor = hubHostedDoor;
413
373
  module.exports.coreUpdateDocketMatters = coreUpdateDocketMatters;
@@ -78,8 +78,7 @@ function makeReceiptStore({ now = Date.now, ttlMs = RECEIPT_TTL_MS, max = RECEIP
78
78
  }
79
79
 
80
80
  // The process's one receipt store. Shared, because a receipt is redeemed at the public
81
- // route below whoever issued it: the CLI's POST /feedback/receipt, or this project's own
82
- // "Ask for this upgrade" (src/bongos/upgrade-request.js, task 1004595).
81
+ // route below whoever issued it (the CLI's POST /feedback/receipt).
83
82
  let sharedStore = null;
84
83
  function defaultReceiptStore() {
85
84
  if (!sharedStore) sharedStore = makeReceiptStore();
@@ -44,7 +44,7 @@ const { buildStampedHtml, hashFile } = require('../asset-version');
44
44
  const { buildInfo } = require('../build-info');
45
45
  const { buildBongosDownloads, installScriptReader } = require('../bongos-downloads');
46
46
  const { clientBranding, branding, applyBrandConditionals } = require('../branding');
47
- const { nightTintCss } = require('../night-tint');
47
+ const { themeMarkup } = require('../theme-markup');
48
48
  const { linkPreviewTags } = require('../link-preview');
49
49
  const { prelaunchGate } = require('./prelaunch-gate');
50
50
  const { platformVisibilityGate } = require('./platform-visibility-gate');
@@ -84,75 +84,10 @@ function tryLoadInstanceConfig(name, roots = [resolveInstanceRoot(), ROOT]) {
84
84
  return null;
85
85
  }
86
86
 
87
- // Map the pack's camelCase theme color keys → the kebab-case CSS custom
88
- // properties the hall/status stylesheets read (public-{builders,status}/style.css
89
- // :root). R57 (task 1196).
90
- const COLOR_VARS = {
91
- bg: '--bg', bgCard: '--bg-card', bgDeep: '--bg-deep', bgFrame: '--bg-frame',
92
- ink: '--ink', inkSoft: '--ink-soft', inkFaint: '--ink-faint',
93
- rule: '--rule', ruleSoft: '--rule-soft',
94
- accent: '--accent', accentDeep: '--accent-deep', sea: '--sea', terracotta: '--terracotta',
95
- };
96
-
97
- // Build the THEME markup for a served page from the client-safe pack's theme:
98
- // a <style> overriding the :root palette + font-family vars, the web-font <link>s,
99
- // and the favicon <link>. Returns '' when the pack carries no theme (so a pack
100
- // without a `theme.ui` block is a no-op and the stylesheet's own defaults stand).
101
- // The <style> is emitted so it lands AFTER the page's own stylesheet <link>
102
- // (which sits earlier in <head>) — equal specificity, later wins — making the
103
- // pack authoritative over style.css. For OTB the values equal style.css's
104
- // parchment defaults, so the render is byte-identical; a neutral instance gets
105
- // its plain palette + system fonts. R57 (task 1196, ADR 0062 §3).
106
- function themeMarkup(theme) {
107
- if (!theme) return '';
108
- const decls = [];
109
- for (const [key, cssVar] of Object.entries(COLOR_VARS)) {
110
- const v = cssSafe(theme.colors?.[key]);
111
- if (v) decls.push(`${cssVar}:${v};`);
112
- }
113
- const display = cssSafe(theme.fonts?.display);
114
- if (display) decls.push(`--font-display:${display};`);
115
- const body = cssSafe(theme.fonts?.body);
116
- if (body) decls.push(`--font-body:${body};`);
117
- let out = '';
118
- for (const href of theme.fonts?.links || []) {
119
- out += `<link rel="stylesheet" href="${esc(href)}">`;
120
- }
121
- // `backdrop` paints the page background (e.g. the Cloud Bongos sunrise gradient,
122
- // ADR 0064 / R74) over the flat var(--bg); omitted packs (OTB) keep --bg.
123
- const css = [];
124
- if (decls.length) css.push(`:root{${decls.join('')}}`);
125
- const backdrop = cssSafe(theme.backdrop);
126
- if (backdrop) css.push(`body{background:${backdrop};}`);
127
- // The night tint (ADR 0358): a hall wearing a look takes a hint of its accent into
128
- // its dark grounds. Written as the sheet's own two dark twins, after the sheet, so
129
- // equal specificity wins on order. clientBranding only sets it with a look, so a
130
- // hall that chose none keeps the sheet's pure black.
131
- if (theme.nightTint === true) css.push(nightTintCss());
132
- if (css.length) out += `<style>${css.join('')}</style>`;
133
- // esc() is lossless in an attribute: the HTML parser decodes the entities back
134
- // before anything reads the URL, so the neutral pack's inline `data:image/svg+xml,
135
- // <svg …>` favicon resolves to exactly the same bytes it does today.
136
- if (theme.favicon) out += `<link rel="icon" href="${esc(theme.favicon)}">`;
137
- if (theme.appleTouchIcon) out += `<link rel="apple-touch-icon" href="${esc(theme.appleTouchIcon)}">`;
138
- return out;
139
- }
140
-
141
- // A pack-supplied value on its way into <style> TEXT. esc() is the wrong tool
142
- // here and would corrupt the output: CSS is not entity-decoded, so a font stack
143
- // like `"Cinzel", serif` would reach the browser as `&quot;Cinzel&quot;, serif`
144
- // and simply not parse. There is no encoding that both survives CSS and
145
- // neutralizes markup, so this fails closed instead. The only way out of a <style>
146
- // element is a literal `</style`, every route to it begins with `<`, and no
147
- // colour, font stack, or backdrop has a legitimate use for one — so a value
148
- // carrying `<` is dropped whole rather than emitted half-escaped. Dropping one
149
- // declaration loses that pack's colour and falls back to style.css's default;
150
- // emitting it loses the page.
151
- function cssSafe(v) {
152
- const s = String(v == null ? '' : v);
153
- return s.includes('<') ? '' : s;
154
- }
155
-
87
+ // The pack's THEME markup (the :root palette + faces <style>, the font links, the
88
+ // favicon) is built by src/theme-markup.js, so a page rendered outside this file —
89
+ // the sign-in screens — gets the identical splice without requiring the server
90
+ // (task 1005135). Re-exported below for every caller that already reads it here.
156
91
  // Serialize a pack value for injection INSIDE a <script> element. JSON.stringify
157
92
  // alone is not enough: its output is valid JSON, but the browser reads a script
158
93
  // body as raw text FIRST, so a `</script>` anywhere in the pack ends the element
@@ -10,8 +10,9 @@
10
10
  // auth error page in routes/auth.js, so every sign-in screen reads as one place.
11
11
  'use strict';
12
12
 
13
- const { branding } = require('../branding');
13
+ const { branding, clientBranding } = require('../branding');
14
14
  const { escapeHtml } = require('../html-escape');
15
+ const { themeMarkup } = require('../theme-markup');
15
16
 
16
17
  const GITHUB_MARK = '<svg aria-hidden="true" width="18" height="18" viewBox="0 0 16 16" style="vertical-align:-3px;margin-right:8px"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59 .4 .07 .55-.17 .55-.38 0-.19-.01-.82-.01-1.49-2.01 .37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53 .63-.01 1.08 .58 1.23 .82 .72 1.21 1.87 .87 2.33 .66 .07-.52 .28-.87 .51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87 .31-1.59 .82-2.15-.08-.2-.36-1.02 .08-2.12 0 0 .67-.21 2.2 .82 .64-.18 1.32-.27 2-.27 .68 0 1.36 .09 2 .27 1.53-1.04 2.2-.82 2.2-.82 .44 1.1 .16 1.92 .08 2.12 .51 .56 .82 1.27 .82 2.15 0 3.07-1.87 3.75-3.65 3.95 .29 .25 .54 .73 .54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21 .15 .46 .55 .38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>';
17
18
  const GOOGLE_MARK = '<svg aria-hidden="true" width="18" height="18" viewBox="0 0 48 48" style="vertical-align:-3px;margin-right:8px"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>';
@@ -20,7 +21,22 @@ function worldName() {
20
21
  try { return branding().identity?.worldName || 'Sign in'; } catch { return 'Sign in'; }
21
22
  }
22
23
 
23
- // One page shell: the hall stylesheet, a header, and a body the caller built
24
+ // The look every sign-in screen shares (task 1005135): the hall stylesheet, then
25
+ // the pack's own palette, faces and favicon, spliced exactly as every served page
26
+ // gets them (src/theme-markup.js, the same splice serve-internal applies). The pack's faces are the only faces;
27
+ // these pages once loaded a retired pair of inscription faces on top of the hall sheet. The
28
+ // header and main are lifted over the hall's fixed plate veil (body::after, black in
29
+ // dark), which otherwise dims the whole page — the hall lifts its shell the same way.
30
+ // A branding hiccup degrades to the hall sheet's own neutral values, never a crash.
31
+ function signInHead() {
32
+ let theme = '';
33
+ try { theme = themeMarkup(clientBranding().theme); } catch { theme = ''; }
34
+ return `<link rel="stylesheet" href="/builders/style.css">
35
+ ${theme}
36
+ <style>body > header, body > main { position: relative; z-index: 1; } body > header.hall { padding: var(--ds-space-md) var(--ds-space-gutter-narrow) 0; } body > main { padding: var(--ds-space-sm) var(--ds-space-gutter-narrow) var(--ds-space-xl); }</style>`;
37
+ }
38
+
39
+ // One page shell: the shared head, a header, and a body the caller built
24
40
  // (already escaped). Never a script: these pages work with forms and links alone.
25
41
  function renderPage({ title, heading, subtitle, bodyHtml }) {
26
42
  return `<!doctype html>
@@ -29,7 +45,7 @@ function renderPage({ title, heading, subtitle, bodyHtml }) {
29
45
  <meta name="viewport" content="width=device-width, initial-scale=1">
30
46
  <meta name="referrer" content="no-referrer">
31
47
  <title>${escapeHtml(worldName())} — ${escapeHtml(title)}</title>
32
- <link rel="stylesheet" href="/builders/style.css">
48
+ ${signInHead()}
33
49
  <style>
34
50
  .signinChoices{display:flex;flex-direction:column;gap:12px;max-width:340px;margin:8px auto 0}
35
51
  .signinChoices .landing__btn{display:block;text-align:center}
@@ -154,4 +170,4 @@ ${choiceButtons(view)}
154
170
  });
155
171
  }
156
172
 
157
- module.exports = { renderSignInChooser, renderExistingAccountHint, renderDeviceApprovalPage };
173
+ module.exports = { renderSignInChooser, renderExistingAccountHint, renderDeviceApprovalPage, signInHead };
package/src/brand-mark.js CHANGED
@@ -16,7 +16,16 @@ const fs = require('node:fs');
16
16
  const path = require('node:path');
17
17
 
18
18
  // The Night ink: the chrome world's ink on a dark ground, which every dark twin of the mark uses.
19
- const NIGHT_INK = '#e2e1e4';
19
+ // It is the pack's deep ground (theme.ui.colors.bgDeep in config/branding.neutral.json), read
20
+ // from the pack the core ships rather than typed in here, so a palette change reaches the mark
21
+ // (task 1005133). Read relative to this file: src/ sits in the core root beside config/.
22
+ function readNightInk() {
23
+ try {
24
+ const pack = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'config', 'branding.neutral.json'), 'utf8'));
25
+ return pack.theme.ui.colors.bgDeep || 'currentColor';
26
+ } catch { return 'currentColor'; }
27
+ }
28
+ const NIGHT_INK = readNightInk();
20
29
  // The two custom properties the source colours itself by, exactly as written there.
21
30
  const INK_VAR = 'var(--mark-ink,currentColor)';
22
31
  const DOT_VAR = 'var(--mark-dot,var(--accent,currentColor))';
package/src/error-page.js CHANGED
@@ -3,7 +3,9 @@
3
3
  // A person who mistypes an address, or lands on a page that broke, used to get a bare
4
4
  // "404 — not found" in plain text: the one screen of the product with no product on it.
5
5
  // This is that screen in the chrome world: the shared mark, the product name, a heading,
6
- // one sentence and a way back, on the pack's colours and faces in both modes.
6
+ // one sentence and a way back, on the pack's colours and faces in both modes. The name
7
+ // beside the mark is the pack's productName, shown lowercase (DESIGN.md "Naming"); the
8
+ // colours are the hall sheet's tier, never a block of this page's own (task 1005135).
7
9
  //
8
10
  // WHO GETS IT: a browser asking for a page. An API-shaped path keeps its JSON (the
9
11
  // self-describing 404 of api-path-404.js; the trailing error net answers the R01
@@ -44,24 +46,23 @@ function errorPageTemplate(status, { notFound = 'Page not found.' } = {}) {
44
46
  <meta name="robots" content="noindex">
45
47
  <title>${escapeHtml(heading)} · {{productName}}</title>
46
48
  <link rel="icon" href="/modules/ui-design/public/brand/favicon.svg" type="image/svg+xml">
47
- <link rel="stylesheet" href="/modules/ui-design/public/tokens.css">
49
+ <!-- The hall's sheet carries the shared scales (tokens.css), the derived colour tier
50
+ and both dark twins, so this page declares no colour of its own (task 1005135). -->
51
+ <link rel="stylesheet" href="/modules/hall-ui/public/style.css">
48
52
  <style>
49
- /* The fallback contract tier at zero specificity: the pack's spliced :root always wins. */
50
- :where(html){--bg:#edecee;--bg-card:#fbfafc;--ink:#17181a;--ink-soft:#55565a;--ink-faint:#65656a;--rule-soft:#dcdbdd;--accent:#fb9569;--accent-deep:#a3481a;--font-display:"Manrope","Segoe UI",system-ui,sans-serif;--font-body:"Manrope","Segoe UI",system-ui,sans-serif}
51
- html{color-scheme:light;--accent-ink:color-mix(in srgb,var(--accent) 45%,#240e00);--focus:color-mix(in srgb,var(--accent-deep) 82%,#3a1400)}
52
- html[data-theme="dark"]{color-scheme:dark;--bg:#000000;--bg-card:#101012;--ink:#ffffff;--ink-soft:#c0c0c0;--ink-faint:#a8a8a8;--rule-soft:rgba(255,255,255,.08);--accent-ink:var(--accent);--focus:var(--accent)}
53
- @media (prefers-color-scheme:dark){html:not([data-theme]){color-scheme:dark;--bg:#000000;--bg-card:#101012;--ink:#ffffff;--ink-soft:#c0c0c0;--ink-faint:#a8a8a8;--rule-soft:rgba(255,255,255,.08);--accent-ink:var(--accent);--focus:var(--accent)}}
54
53
  *{box-sizing:border-box}
55
54
  body{margin:0;min-height:100vh;display:flex;flex-direction:column;background:var(--bg);color:var(--ink);font-family:var(--font-body);font-size:var(--ds-text-2xl,17px);line-height:1.5}
55
+ header,main{position:relative;z-index:1}
56
56
  header{display:flex;align-items:center;gap:var(--ds-space-xs,10px);min-height:56px;padding:0 var(--ds-space-gutter-narrow,20px);border-bottom:var(--ds-line,1px) solid var(--rule-soft)}
57
57
  .mark{width:28px;height:28px;--mark-ink:var(--ink)}
58
- .name{font-family:var(--font-display);font-size:var(--ds-text-xl,16px);font-weight:500;color:var(--ink);text-decoration:none}
58
+ .name{font-family:var(--font-display);font-size:var(--ds-text-lg,15px);font-weight:300;letter-spacing:-.01em;text-transform:lowercase;color:var(--ink);text-decoration:none}
59
59
  main{flex:1;display:flex;align-items:center;justify-content:center;padding:var(--ds-space-xl,52px) var(--ds-space-gutter-narrow,20px)}
60
60
  .card{max-width:34rem}
61
- .code{margin:0 0 var(--ds-space-sm,14px);font-family:"JetBrains Mono",ui-monospace,monospace;font-size:var(--ds-text-2xs,11px);letter-spacing:.14em;text-transform:uppercase;color:var(--ink-soft)}
61
+ .code{margin:0 0 var(--ds-space-sm,14px);font-family:var(--font-mono);font-size:var(--ds-text-2xs,11px);letter-spacing:.14em;text-transform:uppercase;color:var(--ink-soft)}
62
62
  h1{margin:0 0 var(--ds-space-sm,14px);font-family:var(--font-display);font-size:var(--ds-text-headline,clamp(26px,3.1vw,42px));font-weight:300;line-height:1.06;letter-spacing:-.035em}
63
63
  p.sub{margin:0 0 var(--ds-space-md,22px);color:var(--ink-soft)}
64
- a.back{display:inline-flex;align-items:center;min-height:46px;padding:0 var(--ds-space-md,22px);border-radius:var(--ds-radius-pill,999px);background:var(--accent);color:#2a1305;font-weight:500;text-decoration:none}
64
+ a.back{display:inline-flex;align-items:center;min-height:46px;padding:0 var(--ds-space-md,22px);border-radius:var(--ds-radius-pill,999px);background:var(--accent);color:var(--accent-on);font-weight:500;text-decoration:none}
65
+ a.back:hover{background:var(--accent-lift);text-decoration:none}
65
66
  a:focus-visible{outline:2px solid var(--focus);outline-offset:3px;box-shadow:0 0 0 8px var(--bg)}
66
67
  </style>
67
68
  </head>
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.57'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.59'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,81 @@
1
+ // src/theme-markup.js — the branding pack's THEME markup for a served page (R57, task
2
+ // 1196, ADR 0062 §3). Moved out of src/bongos/serve-internal.js (which re-exports it)
3
+ // so a page built outside the server's HTML maps — the sign-in screens in
4
+ // src/bongos/sign-in-pages.js — splices the pack exactly as every other page does,
5
+ // without requiring the server module that mounts the routes that render it
6
+ // (task 1005135). Pure over its input.
7
+ 'use strict';
8
+
9
+ const { escapeHtml: esc } = require('./html-escape');
10
+ const { nightTintCss } = require('./night-tint');
11
+
12
+ // Map the pack's camelCase theme color keys → the kebab-case CSS custom
13
+ // properties the hall/status stylesheets read (public-{builders,status}/style.css
14
+ // :root). R57 (task 1196).
15
+ const COLOR_VARS = {
16
+ bg: '--bg', bgCard: '--bg-card', bgDeep: '--bg-deep', bgFrame: '--bg-frame',
17
+ ink: '--ink', inkSoft: '--ink-soft', inkFaint: '--ink-faint',
18
+ rule: '--rule', ruleSoft: '--rule-soft',
19
+ accent: '--accent', accentDeep: '--accent-deep', sea: '--sea', terracotta: '--terracotta',
20
+ };
21
+
22
+ // Build the THEME markup for a served page from the client-safe pack's theme:
23
+ // a <style> overriding the :root palette + font-family vars, the web-font <link>s,
24
+ // and the favicon <link>. Returns '' when the pack carries no theme (so a pack
25
+ // without a `theme.ui` block is a no-op and the stylesheet's own defaults stand).
26
+ // The <style> is emitted so it lands AFTER the page's own stylesheet <link>
27
+ // (which sits earlier in <head>) — equal specificity, later wins — making the
28
+ // pack authoritative over style.css. For OTB the values equal style.css's
29
+ // parchment defaults, so the render is byte-identical; a neutral instance gets
30
+ // its plain palette + system fonts. R57 (task 1196, ADR 0062 §3).
31
+ function themeMarkup(theme) {
32
+ if (!theme) return '';
33
+ const decls = [];
34
+ for (const [key, cssVar] of Object.entries(COLOR_VARS)) {
35
+ const v = cssSafe(theme.colors?.[key]);
36
+ if (v) decls.push(`${cssVar}:${v};`);
37
+ }
38
+ const display = cssSafe(theme.fonts?.display);
39
+ if (display) decls.push(`--font-display:${display};`);
40
+ const body = cssSafe(theme.fonts?.body);
41
+ if (body) decls.push(`--font-body:${body};`);
42
+ let out = '';
43
+ for (const href of theme.fonts?.links || []) {
44
+ out += `<link rel="stylesheet" href="${esc(href)}">`;
45
+ }
46
+ // `backdrop` paints the page background (e.g. the Cloud Bongos sunrise gradient,
47
+ // ADR 0064 / R74) over the flat var(--bg); omitted packs (OTB) keep --bg.
48
+ const css = [];
49
+ if (decls.length) css.push(`:root{${decls.join('')}}`);
50
+ const backdrop = cssSafe(theme.backdrop);
51
+ if (backdrop) css.push(`body{background:${backdrop};}`);
52
+ // The night tint (ADR 0358): a hall wearing a look takes a hint of its accent into
53
+ // its dark grounds. Written as the sheet's own two dark twins, after the sheet, so
54
+ // equal specificity wins on order. clientBranding only sets it with a look, so a
55
+ // hall that chose none keeps the sheet's pure black.
56
+ if (theme.nightTint === true) css.push(nightTintCss());
57
+ if (css.length) out += `<style>${css.join('')}</style>`;
58
+ // esc() is lossless in an attribute: the HTML parser decodes the entities back
59
+ // before anything reads the URL, so the neutral pack's inline `data:image/svg+xml,
60
+ // <svg …>` favicon resolves to exactly the same bytes it does today.
61
+ if (theme.favicon) out += `<link rel="icon" href="${esc(theme.favicon)}">`;
62
+ if (theme.appleTouchIcon) out += `<link rel="apple-touch-icon" href="${esc(theme.appleTouchIcon)}">`;
63
+ return out;
64
+ }
65
+
66
+ // A pack-supplied value on its way into <style> TEXT. esc() is the wrong tool
67
+ // here and would corrupt the output: CSS is not entity-decoded, so a font stack
68
+ // like `"Cinzel", serif` would reach the browser as `&quot;Cinzel&quot;, serif`
69
+ // and simply not parse. There is no encoding that both survives CSS and
70
+ // neutralizes markup, so this fails closed instead. The only way out of a <style>
71
+ // element is a literal `</style`, every route to it begins with `<`, and no
72
+ // colour, font stack, or backdrop has a legitimate use for one — so a value
73
+ // carrying `<` is dropped whole rather than emitted half-escaped. Dropping one
74
+ // declaration loses that pack's colour and falls back to style.css's default;
75
+ // emitting it loses the page.
76
+ function cssSafe(v) {
77
+ const s = String(v == null ? '' : v);
78
+ return s.includes('<') ? '' : s;
79
+ }
80
+
81
+ module.exports = { themeMarkup };
@@ -100,7 +100,7 @@ test('the brand folder holds exactly what gen-brand.js writes from the source (l
100
100
  const c = packColors();
101
101
  assert.match(files['mark-light.svg'], new RegExp(c.ink));
102
102
  assert.match(files['mark-light.svg'], new RegExp(c.accent));
103
- assert.match(files['mark-dark.svg'], /#e2e1e4/);
103
+ assert.match(files['mark-dark.svg'], new RegExp(c.bgDeep), 'the dark logo is drawn in the pack\'s Night ink (bgDeep)');
104
104
  assert.doesNotMatch(files['mark-mono.svg'], new RegExp(c.accent), 'mono is one ink');
105
105
  assert.match(files['favicon.svg'], /prefers-color-scheme:dark/, 'the tab icon has its night twin');
106
106
  const png = fs.readFileSync(path.join(ROOT, BRAND_DIR, 'apple-touch-icon.png'));
@@ -193,3 +193,13 @@ test('one favicon: every page fallback and the pack point at favicon.svg; a look
193
193
  assert.match(looked.theme.ui.favicon, /stroke-width='24'/, 'the small cut');
194
194
  assert.equal(looked.theme.ui.appleTouchIcon, '', 'the pack-coloured touch icon steps aside');
195
195
  });
196
+
197
+ // task 1005133: the Night ink every dark twin of the mark uses is the pack's bgDeep, read
198
+ // from config/branding.neutral.json — not a hex typed into src/brand-mark.js.
199
+ test('the Night ink comes from the brand pack, not a typed-in colour', () => {
200
+ const { NIGHT_INK, faviconSvg } = require('../src/brand-mark.js');
201
+ const pack = JSON.parse(read('config', 'branding.neutral.json'));
202
+ assert.equal(NIGHT_INK, pack.theme.ui.colors.bgDeep);
203
+ assert.doesNotMatch(read('src', 'brand-mark.js').replace(/\/\/.*$/gm, ''), /#[0-9a-f]{3,8}\b/i, 'src/brand-mark.js types in no colour');
204
+ assert.match(faviconSvg({ ink: '#111111', accent: '#222222' }), new RegExp(`--i:${pack.theme.ui.colors.bgDeep}`), 'a looked favicon\'s night twin');
205
+ });
@@ -144,7 +144,7 @@ test('a page route that throws answers the branded 500 to a browser and JSON to
144
144
  assert.ok(errorPageTemplate(404, { notFound: 'Gone.' }).includes('<h1>Gone.</h1>'));
145
145
  });
146
146
 
147
- test('the error page and the docs write their dark tier twice, byte-identical', () => {
147
+ test('the docs write their dark tier twice, byte-identical; the error page writes none of its own', () => {
148
148
  const twins = (src) => {
149
149
  const a = src.match(/html\[data-theme="dark"\]\s*\{([^}]*)\}/);
150
150
  const b = src.match(/@media \(prefers-color-scheme:\s*dark\)\s*\{\s*html:not\(\[data-theme\]\)\s*\{([^}]*)\}/);
@@ -152,7 +152,10 @@ test('the error page and the docs write their dark tier twice, byte-identical',
152
152
  const norm = (s) => s.replace(/\s+/g, ' ').trim();
153
153
  assert.equal(norm(b[1]), norm(a[1]));
154
154
  };
155
- twins(errorPageTemplate(404));
155
+ // the error page takes its colours and both dark twins from the hall's sheet (task 1005135)
156
+ const ep = errorPageTemplate(404);
157
+ assert.ok(ep.includes('<link rel="stylesheet" href="/modules/hall-ui/public/style.css">'), 'the error page loads the shared sheet');
158
+ assert.doesNotMatch(ep, /data-theme="dark"|prefers-color-scheme/, 'no private dark tier');
156
159
  twins(fs.readFileSync(path.join(ROOT, 'public-docs', 'index.html'), 'utf8'));
157
160
  });
158
161