@bongos/core 1.24.58 → 1.24.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.
@@ -4,7 +4,8 @@
4
4
  // check; kept in its own file because fitness.js sits at the 1,500-line budget.
5
5
  //
6
6
  // Both generated files read the shared token sheet (modules/ui-design/public/tokens.css) and the brand pack; the public
7
- // reference also reads the hall's sheets and DESIGN.md. A stylesheet edit without a
7
+ // reference also reads the hall's sheets and DESIGN.md. The logo set (scripts/bongos/gen-brand.js)
8
+ // reads mark.svg and the pack, and checks its two PNGs by hash (task 1005133). A stylesheet edit without a
8
9
  // regeneration used to surface only as a failing unit test; this names it in fitness.
9
10
  // Skipped when the ui-design module is not in the checkout. `root` is injectable for
10
11
  // tests/design_counts_ratchet.mjs, which runs it against a copied tree.
@@ -17,14 +18,21 @@ const { REPO_ROOT } = require('./fitness-lib.js');
17
18
 
18
19
  function checkDesignGeneratorsFresh(root = REPO_ROOT) {
19
20
  const violations = [];
20
- for (const [script, out, task] of [
21
- ['gen-design-tokens.js', 'config/design-tokens.neutral.json', '1005082'],
22
- ['gen-design-reference.js', 'modules/ui-design/public/brand.html (the public /brand page)', '1005083'],
21
+ const UI = ['modules', 'ui-design', 'scripts'];
22
+ for (const [dir, script, out, why, task] of [
23
+ [UI, 'gen-design-tokens.js', 'config/design-tokens.neutral.json', 'a design source (the token sheet, the brand pack, a hall sheet or DESIGN.md)', '1005082'],
24
+ [UI, 'gen-design-reference.js', 'modules/ui-design/public/brand.html (the public /brand page)', 'a design source (the token sheet, the brand pack, a hall sheet or DESIGN.md)', '1005083'],
25
+ // The logo and favicon set, and the two PNGs by hash (task 1005133): mark.svg or the
26
+ // pack's colours changed without a regeneration. A PNG needs `--png` (a system browser).
27
+ [['scripts', 'bongos'], 'gen-brand.js', 'the logo set in modules/ui-design/public/brand/', 'mark.svg or the brand pack', '1005133'],
23
28
  ]) {
24
- const gen = path.join(root, 'modules', 'ui-design', 'scripts', script);
29
+ const gen = path.join(root, ...dir, script);
25
30
  if (!fs.existsSync(gen)) continue;
26
31
  try { execFileSync(process.execPath, [gen, '--check'], { cwd: root, stdio: 'pipe' }); }
27
- catch { violations.push(`${out} is STALE — a design source (the token sheet, the brand pack, a hall sheet or DESIGN.md) changed without a regeneration. Run \`node modules/ui-design/scripts/${script}\` and commit (task ${task}).`); }
32
+ catch (e) {
33
+ const said = String((e && e.stderr) || '').trim().split('\n').filter(Boolean).join(' ');
34
+ violations.push(`${out} is STALE — ${why} changed without a regeneration. Run \`node ${[...dir, script].join('/')}\`${script === 'gen-brand.js' ? ' (with --png for a PNG)' : ''} and commit (task ${task}).${said ? ` [${said}]` : ''}`);
35
+ }
28
36
  }
29
37
  return violations;
30
38
  }
@@ -17,17 +17,23 @@
17
17
  // og-card.png 1200x630, the link-preview card every public page names (task 1005091):
18
18
  // the dark mark and the lowercase wordmark on the void (--png; needs a browser)
19
19
  //
20
- // Deterministic text, so `--check` is a clean gate (tests/brand_mark.mjs runs it). The PNG is
21
- // rendered by the system browser and only written with --png.
20
+ // Deterministic text, so `--check` is a clean gate (tests/brand_mark.mjs runs it; fitness runs it
21
+ // through scripts/bongos/design-generators-fresh.js). The PNGs are rendered by the system browser
22
+ // and only written with --png; a browser's pixels are not byte-stable across machines, so they
23
+ // are checked by hash instead (task 1005133): --png records, in png-sources.json beside them,
24
+ // a hash of the exact page each was rendered from and a hash of the PNG written. --check fails
25
+ // when the page a PNG would be rendered from today differs from the one recorded (a source or a
26
+ // colour moved without a re-render), or when the PNG is not the one that render wrote.
22
27
  //
23
28
  // Usage:
24
29
  // node scripts/bongos/gen-brand.js # write the SVG variants
25
30
  // node scripts/bongos/gen-brand.js --png # … and render apple-touch-icon.png + og-card.png
26
- // node scripts/bongos/gen-brand.js --check # write nothing; exit 2 if a variant is stale
31
+ // node scripts/bongos/gen-brand.js --check # write nothing; exit 2 if a variant or a PNG is stale
27
32
 
28
33
  const fs = require('node:fs');
29
34
  const path = require('node:path');
30
- const { NIGHT_INK, readMarkSource, markParts, markFile, faviconSvg } = require('../../src/brand-mark');
35
+ const crypto = require('node:crypto');
36
+ const { readMarkSource, markParts, markFile, faviconSvg } = require('../../src/brand-mark');
31
37
  const { escapeHtml } = require('../../src/html-escape');
32
38
 
33
39
  const ROOT = path.resolve(__dirname, '..', '..');
@@ -59,22 +65,71 @@ function ogCardHtml({ markSvg, name, fontLinks = [], fontFamily = 'sans-serif' }
59
65
  '</body></html>';
60
66
  }
61
67
 
62
- async function renderOgCard(dir) {
68
+ // The page apple-touch-icon.png is rendered from: the light mark on the pack's ground. Pure.
69
+ function touchIconHtml({ markSvg, bg }) {
70
+ return `<html><body style="margin:0;background:${bg}">` +
71
+ `<img src="data:image/svg+xml;base64,${Buffer.from(markSvg).toString('base64')}" style="display:block;width:128px;height:128px;margin:26px">` +
72
+ '</body></html>';
73
+ }
74
+
75
+ // Each PNG as the exact page and viewport it is rendered from, given the SVG variants it
76
+ // draws (the text gen-brand writes, not the files on disk, so a stale SVG shows here too).
77
+ function pngPages(files, pack) {
78
+ return {
79
+ 'apple-touch-icon.png': {
80
+ width: 180, height: 180, wait: 'load',
81
+ html: touchIconHtml({ markSvg: files['mark-light.svg'], bg: pack.theme.ui.colors.bg }),
82
+ },
83
+ 'og-card.png': {
84
+ width: 1200, height: 630, wait: 'networkidle',
85
+ html: ogCardHtml({
86
+ markSvg: files['mark-dark.svg'],
87
+ name: pack.identity.productName,
88
+ fontLinks: pack.theme.ui.fonts.links,
89
+ fontFamily: pack.theme.ui.fonts.display,
90
+ }),
91
+ },
92
+ };
93
+ }
94
+
95
+ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
96
+ // The hash of a render's input: the page and the viewport it is shot at.
97
+ function pageHash(p) { return sha256(JSON.stringify([p.width, p.height, p.html])); }
98
+
99
+ const PNG_MANIFEST = 'png-sources.json';
100
+
101
+ // The PNGs that do not match what --png would write today, each with the reason. Pure I/O
102
+ // read of the brand folder; writes nothing.
103
+ function stalePngs(dir, pages) {
104
+ let manifest = {};
105
+ try { manifest = JSON.parse(fs.readFileSync(path.join(dir, PNG_MANIFEST), 'utf8')); } catch { manifest = {}; }
106
+ const stale = [];
107
+ for (const [name, p] of Object.entries(pages)) {
108
+ const rec = manifest[name];
109
+ const file = path.join(dir, name);
110
+ if (!rec || !fs.existsSync(file)) { stale.push(`${name} (not recorded in ${PNG_MANIFEST})`); continue; }
111
+ if (rec.page !== pageHash(p)) stale.push(`${name} (its source page changed since it was rendered)`);
112
+ else if (rec.png !== sha256(fs.readFileSync(file))) stale.push(`${name} (the file is not the one --png wrote)`);
113
+ }
114
+ return stale;
115
+ }
116
+
117
+ async function renderPngs(dir, pages) {
63
118
  const { launchBrowser } = require('../../modules/ui-design/kit/lib'); // eslint-disable-line global-require
64
- const pack = readPack();
65
119
  const browser = await launchBrowser();
120
+ const manifest = {};
66
121
  try {
67
- const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 1 });
68
- await page.setContent(ogCardHtml({
69
- markSvg: fs.readFileSync(path.join(dir, 'mark-dark.svg'), 'utf8'),
70
- name: pack.identity.productName,
71
- fontLinks: pack.theme.ui.fonts.links,
72
- fontFamily: pack.theme.ui.fonts.display,
73
- }));
74
- await page.waitForLoadState('networkidle');
75
- await page.evaluate(() => document.fonts.ready);
76
- await page.screenshot({ path: path.join(dir, 'og-card.png'), omitBackground: false });
122
+ for (const [name, p] of Object.entries(pages)) {
123
+ const page = await browser.newPage({ viewport: { width: p.width, height: p.height }, deviceScaleFactor: 1 });
124
+ await page.setContent(p.html);
125
+ await page.waitForLoadState(p.wait);
126
+ await page.evaluate(() => document.fonts.ready);
127
+ await page.screenshot({ path: path.join(dir, name), omitBackground: false });
128
+ await page.close();
129
+ manifest[name] = { page: pageHash(p), png: sha256(fs.readFileSync(path.join(dir, name))) };
130
+ }
77
131
  } finally { await browser.close(); }
132
+ fs.writeFileSync(path.join(dir, PNG_MANIFEST), JSON.stringify(manifest, null, 2) + '\n');
78
133
  }
79
134
 
80
135
  // { filename: text } for every SVG variant. Pure given the source and the colours.
@@ -84,33 +139,21 @@ function brandFiles(src, colors) {
84
139
  const mono = '#000000';
85
140
  return {
86
141
  'mark-light.svg': markFile(parts, { ink: colors.ink, dot: colors.accent, label: 'logo' }),
87
- 'mark-dark.svg': markFile(parts, { ink: NIGHT_INK, dot: colors.accent, label: 'logo' }),
142
+ 'mark-dark.svg': markFile(parts, { ink: colors.bgDeep, dot: colors.accent, label: 'logo' }),
88
143
  'mark-mono.svg': markFile(parts, { ink: mono, dot: mono, label: 'logo' }),
89
144
  'mark-body.svg': markFile(parts, { ink: mono, dot: mono, only: ['ring', 'disc'] }),
90
145
  'mark-dot.svg': markFile(parts, { ink: mono, dot: mono, only: ['dot'] }),
91
- 'favicon.svg': faviconSvg({ ink: colors.ink, accent: colors.accent }, src) + '\n',
146
+ 'favicon.svg': faviconSvg({ ink: colors.ink, accent: colors.accent, night: colors.bgDeep }, src) + '\n',
92
147
  };
93
148
  }
94
149
 
95
- async function renderTouchIcon(dir, colors) {
96
- const { launchBrowser } = require('../../modules/ui-design/kit/lib'); // eslint-disable-line global-require
97
- const svg = fs.readFileSync(path.join(dir, 'mark-light.svg'), 'utf8');
98
- const browser = await launchBrowser();
99
- try {
100
- const page = await browser.newPage({ viewport: { width: 180, height: 180 }, deviceScaleFactor: 1 });
101
- await page.setContent(`<html><body style="margin:0;background:${colors.bg}">` +
102
- `<img src="data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}" style="display:block;width:128px;height:128px;margin:26px">` +
103
- '</body></html>');
104
- await page.waitForLoadState('load');
105
- await page.screenshot({ path: path.join(dir, 'apple-touch-icon.png'), omitBackground: false });
106
- } finally { await browser.close(); }
107
- }
108
-
109
150
  async function main() {
110
151
  const args = process.argv.slice(2);
111
152
  const dir = brandDir();
112
- const colors = packColors();
153
+ const pack = readPack();
154
+ const colors = pack.theme.ui.colors;
113
155
  const files = brandFiles(readMarkSource({ root: ROOT }), colors);
156
+ const pages = pngPages(files, pack);
114
157
  const stale = [];
115
158
  for (const [name, text] of Object.entries(files)) {
116
159
  const file = path.join(dir, name);
@@ -120,11 +163,14 @@ async function main() {
120
163
  if (!args.includes('--check')) fs.writeFileSync(file, text);
121
164
  }
122
165
  if (args.includes('--check')) {
123
- if (stale.length) { process.stderr.write(`gen-brand: stale — ${stale.join(', ')}. Run: node scripts/bongos/gen-brand.js\n`); process.exit(2); }
124
- process.stdout.write('gen-brand: the logo and favicon set match mark.svg\n');
166
+ const pngs = stalePngs(dir, pages);
167
+ if (stale.length) process.stderr.write(`gen-brand: stale — ${stale.join(', ')}. Run: node scripts/bongos/gen-brand.js\n`);
168
+ if (pngs.length) process.stderr.write(`gen-brand: stale PNG — ${pngs.join('; ')}. Run: node scripts/bongos/gen-brand.js --png\n`);
169
+ if (stale.length || pngs.length) process.exit(2);
170
+ process.stdout.write('gen-brand: the logo and favicon set match mark.svg, and the PNGs match their sources\n');
125
171
  return;
126
172
  }
127
- if (args.includes('--png')) { await renderTouchIcon(dir, colors); await renderOgCard(dir); }
173
+ if (args.includes('--png')) await renderPngs(dir, pages);
128
174
  process.stdout.write(`gen-brand: ${stale.length ? `wrote ${stale.join(', ')}` : 'nothing to write'}${args.includes('--png') ? ' + apple-touch-icon.png + og-card.png' : ''}\n`);
129
175
  }
130
176
 
@@ -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;
@@ -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.58'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.60'; // 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